diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index 8a77074d1f741..f6ea46272316a 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -56,6 +56,7 @@ disabled: - x-pack/plugins/synthetics/e2e/synthetics_run.ts - x-pack/plugins/ux/e2e/synthetics_run.ts - x-pack/plugins/observability/e2e/synthetics_run.ts + - x-pack/plugins/exploratory_view/e2e/synthetics_run.ts # Configs that exist but weren't running in CI when this file was introduced - x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/config.ts diff --git a/.buildkite/pipelines/pull_request/exploratory_view_plugin.yml b/.buildkite/pipelines/pull_request/exploratory_view_plugin.yml new file mode 100644 index 0000000000000..a23443d250ee3 --- /dev/null +++ b/.buildkite/pipelines/pull_request/exploratory_view_plugin.yml @@ -0,0 +1,15 @@ +steps: + - command: .buildkite/scripts/steps/functional/observability_plugin.sh + label: 'Exploratory View @elastic/synthetics Tests' + agents: + queue: n2-4-spot + depends_on: build + timeout_in_minutes: 120 + artifact_paths: + - 'x-pack/plugins/exploratory_view/e2e/.journeys/**/*' + retry: + automatic: + - exit_status: '-1' + limit: 3 + - exit_status: '*' + limit: 1 diff --git a/.buildkite/scripts/pipelines/pull_request/pipeline.ts b/.buildkite/scripts/pipelines/pull_request/pipeline.ts index ac1d5c7ecc077..fdaca3654b15c 100644 --- a/.buildkite/scripts/pipelines/pull_request/pipeline.ts +++ b/.buildkite/scripts/pipelines/pull_request/pipeline.ts @@ -141,10 +141,15 @@ const uploadPipeline = (pipelineContent: string | object) => { pipeline.push(getPipeline('.buildkite/pipelines/pull_request/observability_plugin.yml')); } + if (await doAnyChangesMatch([/^x-pack\/plugins\/exploratory_view/])) { + pipeline.push(getPipeline('.buildkite/pipelines/pull_request/exploratory_view_plugin.yml')); + } + if ( await doAnyChangesMatch([ /^x-pack\/plugins\/synthetics/, /^x-pack\/plugins\/observability\/public\/components\/shared\/exploratory_view/, + /^x-pack\/plugins\/exploratory_view/, ]) ) { pipeline.push(getPipeline('.buildkite/pipelines/pull_request/synthetics_plugin.yml')); @@ -154,6 +159,7 @@ const uploadPipeline = (pipelineContent: string | object) => { await doAnyChangesMatch([ /^x-pack\/plugins\/ux/, /^x-pack\/plugins\/observability\/public\/components\/shared\/exploratory_view/, + /^x-pack\/plugins\/exploratory_view/, ]) ) { pipeline.push(getPipeline('.buildkite/pipelines/pull_request/ux_plugin_e2e.yml')); diff --git a/.eslintrc.js b/.eslintrc.js index 51cd3440b87d5..f98b8524bf36e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -867,6 +867,7 @@ module.exports = { files: [ 'x-pack/plugins/apm/**/*.{js,mjs,ts,tsx}', 'x-pack/plugins/observability/**/*.{js,mjs,ts,tsx}', + 'x-pack/plugins/exploratory_view/**/*.{js,mjs,ts,tsx}', 'x-pack/plugins/ux/**/*.{js,mjs,ts,tsx}', ], rules: { @@ -886,7 +887,11 @@ module.exports = { }, }, { - files: ['x-pack/plugins/apm/**/*.stories.*', 'x-pack/plugins/observability/**/*.stories.*'], + files: [ + 'x-pack/plugins/apm/**/*.stories.*', + 'x-pack/plugins/observability/**/*.stories.*', + 'x-pack/plugins/exploratory_view/**/*.stories.*', + ], rules: { 'react/function-component-definition': [ 'off', @@ -901,6 +906,7 @@ module.exports = { files: [ 'x-pack/plugins/apm/**/*.{js,mjs,ts,tsx}', 'x-pack/plugins/observability/**/*.{js,mjs,ts,tsx}', + 'x-pack/plugins/exploratory_view/**/*.{js,mjs,ts,tsx}', 'x-pack/plugins/ux/**/*.{js,mjs,ts,tsx}', 'x-pack/plugins/synthetics/**/*.{js,mjs,ts,tsx}', 'x-pack/plugins/infra/**/*.{js,mjs,ts,tsx}', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 047087a14f0d7..85cbf8c170ff8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -344,6 +344,7 @@ x-pack/plugins/event_log @elastic/response-ops packages/kbn-expandable-flyout @elastic/security-threat-hunting-investigations packages/kbn-expect @elastic/kibana-operations x-pack/examples/exploratory_view_example @elastic/uptime +x-pack/plugins/exploratory_view @elastic/uptime src/plugins/expression_error @elastic/kibana-presentation src/plugins/chart_expressions/expression_gauge @elastic/kibana-visualizations src/plugins/chart_expressions/expression_heatmap @elastic/kibana-visualizations diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index c3bef9fe0016f..0f284948452c6 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -531,6 +531,10 @@ security and spaces filtering. activities. +|{kib-repo}blob/{branch}/x-pack/plugins/exploratory_view/README.md[exploratoryView] +|A shared component for visualizing observability data types via lens embeddable. For further details. + + |{kib-repo}blob/{branch}/x-pack/plugins/features/README.md[features] |The features plugin enhance Kibana with a per-feature privilege system. diff --git a/package.json b/package.json index 02aade28dfd77..b6b1ae184578c 100644 --- a/package.json +++ b/package.json @@ -375,6 +375,7 @@ "@kbn/event-log-plugin": "link:x-pack/plugins/event_log", "@kbn/expandable-flyout": "link:packages/kbn-expandable-flyout", "@kbn/exploratory-view-example-plugin": "link:x-pack/examples/exploratory_view_example", + "@kbn/exploratory-view-plugin": "link:x-pack/plugins/exploratory_view", "@kbn/expression-error-plugin": "link:src/plugins/expression_error", "@kbn/expression-gauge-plugin": "link:src/plugins/chart_expressions/expression_gauge", "@kbn/expression-heatmap-plugin": "link:src/plugins/chart_expressions/expression_heatmap", diff --git a/packages/kbn-babel-preset/styled_components_files.js b/packages/kbn-babel-preset/styled_components_files.js index 3df97185c8dce..9970e587c9b4f 100644 --- a/packages/kbn-babel-preset/styled_components_files.js +++ b/packages/kbn-babel-preset/styled_components_files.js @@ -14,7 +14,7 @@ module.exports = { USES_STYLED_COMPONENTS: [ /packages[\/\\](kbn-ui-shared-deps-(npm|src)|kbn-ecs-data-quality-dashboard)[\/\\]/, /src[\/\\]plugins[\/\\](kibana_react)[\/\\]/, - /x-pack[\/\\]plugins[\/\\](apm|beats_management|cases|fleet|infra|lists|observability|osquery|security_solution|timelines|synthetics|ux)[\/\\]/, + /x-pack[\/\\]plugins[\/\\](apm|beats_management|cases|fleet|infra|lists|observability|exploratory_view|osquery|security_solution|timelines|synthetics|ux)[\/\\]/, /x-pack[\/\\]test[\/\\]plugin_functional[\/\\]plugins[\/\\]resolver_test[\/\\]/, ], }; diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index ad2fd0490197e..091b016246911 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -40,6 +40,7 @@ pageLoadAssetSize: enterpriseSearch: 35741 esUiShared: 326654 eventAnnotation: 20500 + exploratoryView: 74673 expressionError: 22127 expressionGauge: 25000 expressionHeatmap: 27505 diff --git a/packages/kbn-ts-projects/ts_projects.ts b/packages/kbn-ts-projects/ts_projects.ts index 38573bf4ca9d3..e7e2efb30c0ab 100644 --- a/packages/kbn-ts-projects/ts_projects.ts +++ b/packages/kbn-ts-projects/ts_projects.ts @@ -22,6 +22,7 @@ export const TS_PROJECTS = TsProject.loadAll({ 'x-pack/plugins/synthetics/e2e/tsconfig.json', 'x-pack/plugins/ux/e2e/tsconfig.json', 'x-pack/plugins/observability/e2e/tsconfig.json', + 'x-pack/plugins/exploratory_view/e2e/tsconfig.json', 'x-pack/plugins/threat_intelligence/cypress/tsconfig.json', ], }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts index c105b3dd387f0..1234b8a1c8def 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts @@ -152,6 +152,7 @@ export const applicationUsageSchema = { ml: commonSchema, monitoring: commonSchema, 'observability-overview': commonSchema, + 'exploratory-view': commonSchema, osquery: commonSchema, profiling: commonSchema, security_account: commonSchema, diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 52fe7853bf471..5d47ef2f9756c 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -4325,6 +4325,137 @@ } } }, + "exploratory-view": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "Always `main`" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 90 days" + } + }, + "views": { + "type": "array", + "items": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "The application view being tracked" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application sub view since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application sub view is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 90 days" + } + } + } + } + } + } + }, "osquery": { "properties": { "appId": { diff --git a/tsconfig.base.json b/tsconfig.base.json index eae5649b7f6b0..51cee0c884544 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -682,6 +682,8 @@ "@kbn/expect/*": ["packages/kbn-expect/*"], "@kbn/exploratory-view-example-plugin": ["x-pack/examples/exploratory_view_example"], "@kbn/exploratory-view-example-plugin/*": ["x-pack/examples/exploratory_view_example/*"], + "@kbn/exploratory-view-plugin": ["x-pack/plugins/exploratory_view"], + "@kbn/exploratory-view-plugin/*": ["x-pack/plugins/exploratory_view/*"], "@kbn/expression-error-plugin": ["src/plugins/expression_error"], "@kbn/expression-error-plugin/*": ["src/plugins/expression_error/*"], "@kbn/expression-gauge-plugin": ["src/plugins/chart_expressions/expression_gauge"], diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index fbde4b1e73c7e..153a23d8105a2 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -24,6 +24,7 @@ "xpack.enterpriseSearch": "plugins/enterprise_search", "xpack.features": "plugins/features", "xpack.dataVisualizer": "plugins/data_visualizer", + "xpack.exploratoryView": "plugins/exploratory_view", "xpack.fileUpload": "plugins/file_upload", "xpack.globalSearch": ["plugins/global_search"], "xpack.globalSearchBar": ["plugins/global_search_bar"], diff --git a/x-pack/plugins/exploratory_view/.storybook/jest_setup.js b/x-pack/plugins/exploratory_view/.storybook/jest_setup.js new file mode 100644 index 0000000000000..32071b8aa3f62 --- /dev/null +++ b/x-pack/plugins/exploratory_view/.storybook/jest_setup.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setGlobalConfig } from '@storybook/testing-react'; +import * as globalStorybookConfig from './preview'; + +setGlobalConfig(globalStorybookConfig); diff --git a/x-pack/plugins/exploratory_view/.storybook/main.js b/x-pack/plugins/exploratory_view/.storybook/main.js new file mode 100644 index 0000000000000..86b48c32f103e --- /dev/null +++ b/x-pack/plugins/exploratory_view/.storybook/main.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/x-pack/plugins/exploratory_view/.storybook/preview.js b/x-pack/plugins/exploratory_view/.storybook/preview.js new file mode 100644 index 0000000000000..3200746243d47 --- /dev/null +++ b/x-pack/plugins/exploratory_view/.storybook/preview.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiThemeProviderDecorator } from '@kbn/kibana-react-plugin/common'; + +export const decorators = [EuiThemeProviderDecorator]; diff --git a/x-pack/plugins/exploratory_view/README.md b/x-pack/plugins/exploratory_view/README.md new file mode 100644 index 0000000000000..417697988d649 --- /dev/null +++ b/x-pack/plugins/exploratory_view/README.md @@ -0,0 +1,27 @@ +# Exploratory View plugin + +A shared component for visualizing observability data types via lens embeddable. [For further details.](./public/components/exploratory_view/README.md) + +## Unit testing + +Note: Run the following commands from `kibana/x-pack/plugins/exploratory_view`. + +### Run unit tests + +```bash +npx jest --watch +``` + +### Update snapshots + +```bash +npx jest --updateSnapshot +``` + +### Coverage + +HTML coverage report can be found in target/coverage/jest after tests have run. + +```bash +open target/coverage/jest/index.html +``` diff --git a/x-pack/plugins/exploratory_view/common/annotations.ts b/x-pack/plugins/exploratory_view/common/annotations.ts new file mode 100644 index 0000000000000..e9b7c6b77b231 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/annotations.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; + +/** + * Checks whether a string is a valid ISO timestamp, + * but doesn't convert it into a Date object when decoding. + * + * Copied from x-pack/plugins/apm/common/runtime_types/date_as_string_rt.ts. + */ +const dateAsStringRt = new t.Type( + 'DateAsString', + t.string.is, + (input, context) => + either.chain(t.string.validate(input, context), (str) => { + const date = new Date(str); + return isNaN(date.getTime()) ? t.failure(input, context) : t.success(str); + }), + t.identity +); + +export const createAnnotationRt = t.intersection([ + t.type({ + annotation: t.type({ + type: t.string, + }), + '@timestamp': dateAsStringRt, + message: t.string, + }), + t.partial({ + tags: t.array(t.string), + service: t.partial({ + name: t.string, + environment: t.string, + version: t.string, + }), + }), +]); + +export const deleteAnnotationRt = t.type({ + id: t.string, +}); + +export const getAnnotationByIdRt = t.type({ + id: t.string, +}); + +export interface Annotation { + annotation: { + type: string; + }; + tags?: string[]; + message: string; + service?: { + name?: string; + environment?: string; + version?: string; + }; + event: { + created: string; + }; + '@timestamp': string; +} diff --git a/x-pack/plugins/exploratory_view/common/constants.ts b/x-pack/plugins/exploratory_view/common/constants.ts new file mode 100644 index 0000000000000..e4569aec7c0e6 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const ALERT_STATUS_ALL = 'all'; diff --git a/x-pack/plugins/exploratory_view/common/field_names/infra_logs.ts b/x-pack/plugins/exploratory_view/common/field_names/infra_logs.ts new file mode 100644 index 0000000000000..4e81264fd7fbd --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/field_names/infra_logs.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DOCUMENT_FIELD_NAME } from '@kbn/lens-plugin/common'; + +export const LOG_RATE = DOCUMENT_FIELD_NAME; diff --git a/x-pack/plugins/exploratory_view/common/field_names/infra_metrics.ts b/x-pack/plugins/exploratory_view/common/field_names/infra_metrics.ts new file mode 100644 index 0000000000000..26683dd2a206e --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/field_names/infra_metrics.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const SYSTEM_CPU_PERCENTAGE_FIELD = 'system.cpu.total.norm.pct'; +export const SYSTEM_MEMORY_PERCENTAGE_FIELD = 'system.memory.used.pct'; +export const DOCKER_CPU_PERCENTAGE_FIELD = 'docker.cpu.total.pct'; +export const K8S_POD_CPU_PERCENTAGE_FIELD = 'kubernetes.pod.cpu.usage.node.pct'; diff --git a/x-pack/plugins/exploratory_view/common/field_names/synthetics.ts b/x-pack/plugins/exploratory_view/common/field_names/synthetics.ts new file mode 100644 index 0000000000000..003be106ffaaa --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/field_names/synthetics.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const MONITOR_DURATION_US = 'monitor.duration.us'; +export const SYNTHETICS_CLS = 'browser.experience.cls'; +export const SYNTHETICS_LCP = 'browser.experience.lcp.us'; +export const SYNTHETICS_FCP = 'browser.experience.fcp.us'; +export const SYNTHETICS_DOCUMENT_ONLOAD = 'browser.experience.load.us'; +export const SYNTHETICS_DCL = 'browser.experience.dcl.us'; +export const SYNTHETICS_STEP_NAME = 'synthetics.step.name.keyword'; +export const SYNTHETICS_STEP_DURATION = 'synthetics.step.duration.us'; + +export const SYNTHETICS_DNS_TIMINGS = 'synthetics.payload.timings.dns'; +export const SYNTHETICS_SSL_TIMINGS = 'synthetics.payload.timings.ssl'; +export const SYNTHETICS_BLOCKED_TIMINGS = 'synthetics.payload.timings.blocked'; +export const SYNTHETICS_CONNECT_TIMINGS = 'synthetics.payload.timings.connect'; +export const SYNTHETICS_RECEIVE_TIMINGS = 'synthetics.payload.timings.receive'; +export const SYNTHETICS_SEND_TIMINGS = 'synthetics.payload.timings.send'; +export const SYNTHETICS_WAIT_TIMINGS = 'synthetics.payload.timings.wait'; +export const SYNTHETICS_TOTAL_TIMINGS = 'synthetics.payload.timings.total'; + +export const NETWORK_TIMINGS_FIELDS = [ + SYNTHETICS_DNS_TIMINGS, + SYNTHETICS_SSL_TIMINGS, + SYNTHETICS_BLOCKED_TIMINGS, + SYNTHETICS_CONNECT_TIMINGS, + SYNTHETICS_RECEIVE_TIMINGS, + SYNTHETICS_SEND_TIMINGS, + SYNTHETICS_WAIT_TIMINGS, + SYNTHETICS_TOTAL_TIMINGS, +]; diff --git a/x-pack/plugins/exploratory_view/common/i18n.ts b/x-pack/plugins/exploratory_view/common/i18n.ts new file mode 100644 index 0000000000000..68fb35126afd7 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/i18n.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const NOT_AVAILABLE_LABEL = i18n.translate('xpack.exploratoryView.notAvailable', { + defaultMessage: 'N/A', +}); diff --git a/x-pack/plugins/exploratory_view/common/index.ts b/x-pack/plugins/exploratory_view/common/index.ts new file mode 100644 index 0000000000000..0d25da5f97561 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/index.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { AsDuration, AsPercent, TimeUnitChar, TimeFormatter } from './utils/formatters'; + +export { + formatDurationFromTimeUnitChar, + asPercent, + getDurationFormatter, +} from './utils/formatters'; +export { getInspectResponse } from './utils/get_inspect_response'; + +export { ProcessorEvent } from './processor_event'; + +export { + enableInspectEsQueries, + maxSuggestions, + enableComparisonByDefault, + defaultApmServiceEnvironment, + apmProgressiveLoading, + apmServiceInventoryOptimizedSorting, + apmServiceGroupMaxNumberOfServices, + apmTraceExplorerTab, + apmLabsButton, + enableInfrastructureHostsView, + enableAwsLambdaMetrics, + enableAgentExplorerView, + apmAWSLambdaPriceFactor, + apmAWSLambdaRequestCostPerMillion, + apmEnableServiceMetrics, + apmEnableContinuousRollups, + enableCriticalPath, + profilingElasticsearchPlugin, +} from './ui_settings_keys'; + +export { + ProgressiveLoadingQuality, + getProbabilityFromProgressiveLoadingQuality, +} from './progressive_loading'; + +export const sloFeatureId = 'slo'; + +export const casesFeatureId = 'observabilityCases'; + +// The ID of the observability app. Should more appropriately be called +// 'observability' but it's used in telemetry by applicationUsage so we don't +// want to change it. +export const observabilityAppId = 'observability-overview'; + +// Used by Cases to install routes +export const casesPath = '/cases'; + +// Name of a locator created by the uptime plugin. Intended for use +// by other plugins as well, so defined here to prevent cross-references. +export const uptimeOverviewLocatorID = 'UPTIME_OVERVIEW_LOCATOR'; +export const syntheticsMonitorDetailLocatorID = 'SYNTHETICS_MONITOR_DETAIL_LOCATOR'; +export const syntheticsEditMonitorLocatorID = 'SYNTHETICS_EDIT_MONITOR_LOCATOR'; +export const ruleDetailsLocatorID = 'RULE_DETAILS_LOCATOR'; + +export { + NETWORK_TIMINGS_FIELDS, + SYNTHETICS_BLOCKED_TIMINGS, + SYNTHETICS_CONNECT_TIMINGS, + SYNTHETICS_DNS_TIMINGS, + SYNTHETICS_RECEIVE_TIMINGS, + SYNTHETICS_SEND_TIMINGS, + SYNTHETICS_SSL_TIMINGS, + SYNTHETICS_STEP_DURATION, + SYNTHETICS_TOTAL_TIMINGS, + SYNTHETICS_WAIT_TIMINGS, +} from './field_names/synthetics'; diff --git a/x-pack/plugins/exploratory_view/common/processor_event.ts b/x-pack/plugins/exploratory_view/common/processor_event.ts new file mode 100644 index 0000000000000..5bbc327e9f1b1 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/processor_event.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum ProcessorEvent { + transaction = 'transaction', + error = 'error', + metric = 'metric', + span = 'span', +} diff --git a/x-pack/plugins/exploratory_view/common/progressive_loading.ts b/x-pack/plugins/exploratory_view/common/progressive_loading.ts new file mode 100644 index 0000000000000..f4a97d3c6b5a3 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/progressive_loading.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum ProgressiveLoadingQuality { + low = 'low', + medium = 'medium', + high = 'high', + off = 'off', +} + +export function getProbabilityFromProgressiveLoadingQuality( + quality: ProgressiveLoadingQuality +): number { + switch (quality) { + case ProgressiveLoadingQuality.high: + return 0.1; + + case ProgressiveLoadingQuality.medium: + return 0.01; + + case ProgressiveLoadingQuality.low: + return 0.001; + + case ProgressiveLoadingQuality.off: + return 1; + } +} diff --git a/x-pack/plugins/exploratory_view/common/typings.ts b/x-pack/plugins/exploratory_view/common/typings.ts new file mode 100644 index 0000000000000..ce2fdb7460688 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/typings.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED } from '@kbn/rule-data-utils'; +import { ALERT_STATUS_ALL } from './constants'; + +export type Maybe = T | null | undefined; + +export const alertWorkflowStatusRt = t.keyof({ + open: null, + acknowledged: null, + closed: null, +}); +export type AlertWorkflowStatus = t.TypeOf; + +export interface ApmIndicesConfig { + error: string; + onboarding: string; + span: string; + transaction: string; + metric: string; +} + +export type AlertStatus = + | typeof ALERT_STATUS_ACTIVE + | typeof ALERT_STATUS_RECOVERED + | typeof ALERT_STATUS_ALL; + +export interface AlertStatusFilter { + status: AlertStatus; + query: string; + label: string; +} diff --git a/x-pack/plugins/exploratory_view/common/ui_settings_keys.ts b/x-pack/plugins/exploratory_view/common/ui_settings_keys.ts new file mode 100644 index 0000000000000..80f94c34a647a --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/ui_settings_keys.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const enableInspectEsQueries = 'observability:enableInspectEsQueries'; +export const maxSuggestions = 'observability:maxSuggestions'; +export const enableComparisonByDefault = 'observability:enableComparisonByDefault'; +export const defaultApmServiceEnvironment = 'observability:apmDefaultServiceEnvironment'; +export const apmProgressiveLoading = 'observability:apmProgressiveLoading'; +export const apmServiceInventoryOptimizedSorting = + 'observability:apmServiceInventoryOptimizedSorting'; +export const apmServiceGroupMaxNumberOfServices = + 'observability:apmServiceGroupMaxNumberOfServices'; +export const apmTraceExplorerTab = 'observability:apmTraceExplorerTab'; +export const apmLabsButton = 'observability:apmLabsButton'; +export const enableInfrastructureHostsView = 'observability:enableInfrastructureHostsView'; +export const enableAwsLambdaMetrics = 'observability:enableAwsLambdaMetrics'; +export const enableAgentExplorerView = 'observability:apmAgentExplorerView'; +export const apmAWSLambdaPriceFactor = 'observability:apmAWSLambdaPriceFactor'; +export const apmAWSLambdaRequestCostPerMillion = 'observability:apmAWSLambdaRequestCostPerMillion'; +export const enableCriticalPath = 'observability:apmEnableCriticalPath'; +export const apmEnableServiceMetrics = 'observability:apmEnableServiceMetrics'; +export const apmEnableContinuousRollups = 'observability:apmEnableContinuousRollups'; +export const profilingElasticsearchPlugin = 'observability:profilingElasticsearchPlugin'; diff --git a/x-pack/plugins/exploratory_view/common/utils/array_union_to_callable.ts b/x-pack/plugins/exploratory_view/common/utils/array_union_to_callable.ts new file mode 100644 index 0000000000000..f376f7cd4ef21 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/array_union_to_callable.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ValuesType } from 'utility-types'; + +// work around a TypeScript limitation described in https://stackoverflow.com/posts/49511416 + +export const arrayUnionToCallable = (array: T): Array> => { + return array; +}; diff --git a/x-pack/plugins/exploratory_view/common/utils/as_mutable_array.ts b/x-pack/plugins/exploratory_view/common/utils/as_mutable_array.ts new file mode 100644 index 0000000000000..ce1d7e607ec4c --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/as_mutable_array.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// Sometimes we use `as const` to have a more specific type, +// because TypeScript by default will widen the value type of an +// array literal. Consider the following example: +// +// const filter = [ +// { term: { 'agent.name': 'nodejs' } }, +// { range: { '@timestamp': { gte: 'now-15m ' }} +// ]; + +// The result value type will be: + +// const filter: ({ +// term: { +// 'agent.name'?: string +// }; +// range?: undefined +// } | { +// term?: undefined; +// range: { +// '@timestamp': { +// gte: string +// } +// } +// })[]; + +// This can sometimes leads to issues. In those cases, we can +// use `as const`. However, the Readonly type is not compatible +// with Array. This function returns a mutable version of a type. + +export function asMutableArray>( + arr: T +): T extends Readonly<[...infer U]> ? U : unknown[] { + return arr as any; +} diff --git a/x-pack/plugins/exploratory_view/common/utils/formatters/datetime.test.ts b/x-pack/plugins/exploratory_view/common/utils/formatters/datetime.test.ts new file mode 100644 index 0000000000000..aaf0b1e574221 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/formatters/datetime.test.ts @@ -0,0 +1,194 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment-timezone'; +import { asRelativeDateTimeRange, asAbsoluteDateTime, getDateDifference } from './datetime'; + +describe('date time formatters', () => { + beforeAll(() => { + moment.tz.setDefault('Europe/Amsterdam'); + }); + afterAll(() => moment.tz.setDefault('')); + describe('asRelativeDateTimeRange', () => { + const formatDateToTimezone = (dateTimeString: string) => moment(dateTimeString).valueOf(); + describe('YYYY - YYYY', () => { + it('range: 10 years', () => { + const start = formatDateToTimezone('2000-01-01 10:01:01'); + const end = formatDateToTimezone('2010-01-01 10:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('2000 - 2010'); + }); + it('range: 5 years', () => { + const start = formatDateToTimezone('2010-01-01 10:01:01'); + const end = formatDateToTimezone('2015-01-01 10:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('2010 - 2015'); + }); + }); + describe('MMM YYYY - MMM YYYY', () => { + it('range: 4 years ', () => { + const start = formatDateToTimezone('2010-01-01 10:01:01'); + const end = formatDateToTimezone('2014-04-01 10:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Jan 2010 - Apr 2014'); + }); + it('range: 6 months ', () => { + const start = formatDateToTimezone('2019-01-01 10:01:01'); + const end = formatDateToTimezone('2019-07-01 10:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Jan 2019 - Jul 2019'); + }); + }); + describe('MMM D, YYYY - MMM D, YYYY', () => { + it('range: 2 days', () => { + const start = formatDateToTimezone('2019-10-01 10:01:01'); + const end = formatDateToTimezone('2019-10-05 10:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 1, 2019 - Oct 5, 2019'); + }); + it('range: 1 day', () => { + const start = formatDateToTimezone('2019-10-01 10:01:01'); + const end = formatDateToTimezone('2019-10-03 10:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 1, 2019 - Oct 3, 2019'); + }); + }); + describe('MMM D, YYYY, HH:mm - HH:mm (UTC)', () => { + it('range: 9 hours', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01'); + const end = formatDateToTimezone('2019-10-29 19:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01 - 19:01 (UTC+1)'); + }); + it('range: 5 hours', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01'); + const end = formatDateToTimezone('2019-10-29 15:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01 - 15:01 (UTC+1)'); + }); + it('range: 14 minutes', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01'); + const end = formatDateToTimezone('2019-10-29 10:15:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01 - 10:15 (UTC+1)'); + }); + it('range: 5 minutes', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01'); + const end = formatDateToTimezone('2019-10-29 10:06:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01 - 10:06 (UTC+1)'); + }); + it('range: 1 minute', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01'); + const end = formatDateToTimezone('2019-10-29 10:02:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01 - 10:02 (UTC+1)'); + }); + }); + describe('MMM D, YYYY, HH:mm:ss - HH:mm:ss (UTC)', () => { + it('range: 50 seconds', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01'); + const end = formatDateToTimezone('2019-10-29 10:01:50'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01:01 - 10:01:50 (UTC+1)'); + }); + it('range: 10 seconds', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01'); + const end = formatDateToTimezone('2019-10-29 10:01:11'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01:01 - 10:01:11 (UTC+1)'); + }); + }); + describe('MMM D, YYYY, HH:mm:ss.SSS - HH:mm:ss.SSS (UTC)', () => { + it('range: 9 seconds', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01.001'); + const end = formatDateToTimezone('2019-10-29 10:01:10.002'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01:01.001 - 10:01:10.002 (UTC+1)'); + }); + it('range: 1 second', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01.001'); + const end = formatDateToTimezone('2019-10-29 10:01:02.002'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01:01.001 - 10:01:02.002 (UTC+1)'); + }); + }); + }); + + describe('asAbsoluteDateTime', () => { + afterAll(() => moment.tz.setDefault('')); + + it('should add a leading plus for timezones with positive UTC offset', () => { + moment.tz.setDefault('Europe/Copenhagen'); + expect(asAbsoluteDateTime(1559390400000, 'minutes')).toBe('Jun 1, 2019, 14:00 (UTC+2)'); + }); + + it('should add a leading minus for timezones with negative UTC offset', () => { + moment.tz.setDefault('America/Los_Angeles'); + expect(asAbsoluteDateTime(1559390400000, 'minutes')).toBe('Jun 1, 2019, 05:00 (UTC-7)'); + }); + + it('should use default UTC offset formatting when offset contains minutes', () => { + moment.tz.setDefault('Canada/Newfoundland'); + expect(asAbsoluteDateTime(1559390400000, 'minutes')).toBe('Jun 1, 2019, 09:30 (UTC-02:30)'); + }); + + it('should respect DST', () => { + moment.tz.setDefault('Europe/Copenhagen'); + const timeWithDST = 1559390400000; // Jun 1, 2019 + const timeWithoutDST = 1575201600000; // Dec 1, 2019 + + expect(asAbsoluteDateTime(timeWithDST)).toBe('Jun 1, 2019, 14:00:00.000 (UTC+2)'); + + expect(asAbsoluteDateTime(timeWithoutDST)).toBe('Dec 1, 2019, 13:00:00.000 (UTC+1)'); + }); + }); + describe('getDateDifference', () => { + it('milliseconds', () => { + const start = moment('2019-10-29 08:00:00.001'); + const end = moment('2019-10-29 08:00:00.005'); + expect(getDateDifference({ start, end, unitOfTime: 'milliseconds' })).toEqual(4); + }); + it('seconds', () => { + const start = moment('2019-10-29 08:00:00'); + const end = moment('2019-10-29 08:00:10'); + expect(getDateDifference({ start, end, unitOfTime: 'seconds' })).toEqual(10); + }); + it('minutes', () => { + const start = moment('2019-10-29 08:00:00'); + const end = moment('2019-10-29 08:15:00'); + expect(getDateDifference({ start, end, unitOfTime: 'minutes' })).toEqual(15); + }); + it('hours', () => { + const start = moment('2019-10-29 08:00:00'); + const end = moment('2019-10-29 10:00:00'); + expect(getDateDifference({ start, end, unitOfTime: 'hours' })).toEqual(2); + }); + it('days', () => { + const start = moment('2019-10-29 08:00:00'); + const end = moment('2019-10-30 10:00:00'); + expect(getDateDifference({ start, end, unitOfTime: 'days' })).toEqual(1); + }); + it('months', () => { + const start = moment('2019-10-29 08:00:00'); + const end = moment('2019-12-29 08:00:00'); + expect(getDateDifference({ start, end, unitOfTime: 'months' })).toEqual(2); + }); + it('years', () => { + const start = moment('2019-10-29 08:00:00'); + const end = moment('2020-10-29 08:00:00'); + expect(getDateDifference({ start, end, unitOfTime: 'years' })).toEqual(1); + }); + it('precise days', () => { + const start = moment('2019-10-29 08:00:00'); + const end = moment('2019-10-30 10:00:00'); + expect(getDateDifference({ start, end, unitOfTime: 'days', precise: true })).toEqual( + 1.0833333333333333 + ); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/common/utils/formatters/datetime.ts b/x-pack/plugins/exploratory_view/common/utils/formatters/datetime.ts new file mode 100644 index 0000000000000..ebb332797ad2e --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/formatters/datetime.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment-timezone'; + +/** + * Returns the timezone set on momentTime. + * (UTC+offset) when offset if bigger than 0. + * (UTC-offset) when offset if lower than 0. + * @param momentTime Moment + */ +function formatTimezone(momentTime: moment.Moment) { + const DEFAULT_TIMEZONE_FORMAT = 'Z'; + + const utcOffsetHours = momentTime.utcOffset() / 60; + + const customTimezoneFormat = utcOffsetHours > 0 ? `+${utcOffsetHours}` : utcOffsetHours; + + const utcOffsetFormatted = Number.isInteger(utcOffsetHours) + ? customTimezoneFormat + : DEFAULT_TIMEZONE_FORMAT; + + return momentTime.format(`(UTC${utcOffsetFormatted})`); +} + +export type TimeUnit = 'hours' | 'minutes' | 'seconds' | 'milliseconds'; +function getTimeFormat(timeUnit: TimeUnit) { + switch (timeUnit) { + case 'hours': + return 'HH'; + case 'minutes': + return 'HH:mm'; + case 'seconds': + return 'HH:mm:ss'; + case 'milliseconds': + return 'HH:mm:ss.SSS'; + default: + return ''; + } +} + +type DateUnit = 'days' | 'months' | 'years'; +function getDateFormat(dateUnit: DateUnit) { + switch (dateUnit) { + case 'years': + return 'YYYY'; + case 'months': + return 'MMM YYYY'; + case 'days': + return 'MMM D, YYYY'; + default: + return ''; + } +} + +export const getDateDifference = ({ + start, + end, + unitOfTime, + precise, +}: { + start: moment.Moment; + end: moment.Moment; + unitOfTime: DateUnit | TimeUnit; + precise?: boolean; +}) => end.diff(start, unitOfTime, precise); + +function getFormatsAccordingToDateDifference(start: moment.Moment, end: moment.Moment) { + if (getDateDifference({ start, end, unitOfTime: 'years' }) >= 5) { + return { dateFormat: getDateFormat('years') }; + } + + if (getDateDifference({ start, end, unitOfTime: 'months' }) >= 5) { + return { dateFormat: getDateFormat('months') }; + } + + const dateFormatWithDays = getDateFormat('days'); + if (getDateDifference({ start, end, unitOfTime: 'days' }) > 1) { + return { dateFormat: dateFormatWithDays }; + } + + if (getDateDifference({ start, end, unitOfTime: 'minutes' }) >= 1) { + return { + dateFormat: dateFormatWithDays, + timeFormat: getTimeFormat('minutes'), + }; + } + + if (getDateDifference({ start, end, unitOfTime: 'seconds' }) >= 10) { + return { + dateFormat: dateFormatWithDays, + timeFormat: getTimeFormat('seconds'), + }; + } + + return { + dateFormat: dateFormatWithDays, + timeFormat: getTimeFormat('milliseconds'), + }; +} + +export function asAbsoluteDateTime(time: number, timeUnit: TimeUnit = 'milliseconds') { + const momentTime = moment(time); + const formattedTz = formatTimezone(momentTime); + + return momentTime.format(`${getDateFormat('days')}, ${getTimeFormat(timeUnit)} ${formattedTz}`); +} + +/** + * + * Returns the dates formatted according to the difference between the two dates: + * + * | Difference | Format | + * | -------------- |:----------------------------------------------:| + * | >= 5 years | YYYY - YYYY | + * | >= 5 months | MMM YYYY - MMM YYYY | + * | > 1 day | MMM D, YYYY - MMM D, YYYY | + * | >= 1 minute | MMM D, YYYY, HH:mm - HH:mm (UTC) | + * | >= 10 seconds | MMM D, YYYY, HH:mm:ss - HH:mm:ss (UTC) | + * | default | MMM D, YYYY, HH:mm:ss.SSS - HH:mm:ss.SSS (UTC) | + * + * @param start timestamp + * @param end timestamp + */ +export function asRelativeDateTimeRange(start: number, end: number) { + const momentStartTime = moment(start); + const momentEndTime = moment(end); + + const { dateFormat, timeFormat } = getFormatsAccordingToDateDifference( + momentStartTime, + momentEndTime + ); + + if (timeFormat) { + const startFormatted = momentStartTime.format(`${dateFormat}, ${timeFormat}`); + const endFormatted = momentEndTime.format(timeFormat); + const formattedTz = formatTimezone(momentStartTime); + return `${startFormatted} - ${endFormatted} ${formattedTz}`; + } + + const startFormatted = momentStartTime.format(dateFormat); + const endFormatted = momentEndTime.format(dateFormat); + return `${startFormatted} - ${endFormatted}`; +} diff --git a/x-pack/plugins/exploratory_view/common/utils/formatters/duration.test.ts b/x-pack/plugins/exploratory_view/common/utils/formatters/duration.test.ts new file mode 100644 index 0000000000000..422da81320926 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/formatters/duration.test.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + asDuration, + asTransactionRate, + toMicroseconds, + asMillisecondDuration, + formatDurationFromTimeUnitChar, +} from './duration'; + +describe('duration formatters', () => { + describe('asDuration', () => { + it('formats correctly with defaults', () => { + expect(asDuration(null)).toEqual('N/A'); + expect(asDuration(undefined)).toEqual('N/A'); + expect(asDuration(0)).toEqual('0 μs'); + expect(asDuration(1)).toEqual('1 μs'); + expect(asDuration(toMicroseconds(1, 'milliseconds'))).toEqual('1,000 μs'); + expect(asDuration(toMicroseconds(1000, 'milliseconds'))).toEqual('1,000 ms'); + expect(asDuration(toMicroseconds(10000, 'milliseconds'))).toEqual('10,000 ms'); + expect(asDuration(toMicroseconds(20, 'seconds'))).toEqual('20 s'); + expect(asDuration(toMicroseconds(10, 'minutes'))).toEqual('600 s'); + expect(asDuration(toMicroseconds(11, 'minutes'))).toEqual('11 min'); + expect(asDuration(toMicroseconds(1, 'hours'))).toEqual('60 min'); + expect(asDuration(toMicroseconds(1.5, 'hours'))).toEqual('90 min'); + expect(asDuration(toMicroseconds(10, 'hours'))).toEqual('600 min'); + expect(asDuration(toMicroseconds(11, 'hours'))).toEqual('11 h'); + }); + + it('falls back to default value', () => { + expect(asDuration(undefined, { defaultValue: 'nope' })).toEqual('nope'); + }); + }); + + describe('toMicroseconds', () => { + it('transformes to microseconds', () => { + expect(toMicroseconds(1, 'hours')).toEqual(3600000000); + expect(toMicroseconds(10, 'minutes')).toEqual(600000000); + expect(toMicroseconds(10, 'seconds')).toEqual(10000000); + expect(toMicroseconds(10, 'milliseconds')).toEqual(10000); + }); + }); + + describe('asTransactionRate', () => { + it.each([ + [Infinity, 'N/A'], + [-Infinity, 'N/A'], + [null, 'N/A'], + [undefined, 'N/A'], + [NaN, 'N/A'], + ])( + 'displays the not available label when the number is not finite', + (value, formattedValue) => { + expect(asTransactionRate(value)).toBe(formattedValue); + } + ); + + it.each([ + [0, '0 tpm'], + [0.005, '< 0.1 tpm'], + ])( + 'displays the correct label when the number is positive and less than 1', + (value, formattedValue) => { + expect(asTransactionRate(value)).toBe(formattedValue); + } + ); + + it.each([ + [1, '1.0 tpm'], + [10, '10.0 tpm'], + [100, '100.0 tpm'], + [1000, '1,000.0 tpm'], + [1000000, '1,000,000.0 tpm'], + ])( + 'displays the correct label when the number is a positive integer and has zero decimals', + (value, formattedValue) => { + expect(asTransactionRate(value)).toBe(formattedValue); + } + ); + + it.each([ + [1.23, '1.2 tpm'], + [12.34, '12.3 tpm'], + [123.45, '123.5 tpm'], + [1234.56, '1,234.6 tpm'], + [1234567.89, '1,234,567.9 tpm'], + ])( + 'displays the correct label when the number is positive and has decimal part', + (value, formattedValue) => { + expect(asTransactionRate(value)).toBe(formattedValue); + } + ); + + it.each([ + [-1, '< 0.1 tpm'], + [-10, '< 0.1 tpm'], + [-100, '< 0.1 tpm'], + [-1000, '< 0.1 tpm'], + [-1000000, '< 0.1 tpm'], + ])( + 'displays the correct label when the number is a negative integer and has zero decimals', + (value, formattedValue) => { + expect(asTransactionRate(value)).toBe(formattedValue); + } + ); + + it.each([ + [-1.23, '< 0.1 tpm'], + [-12.34, '< 0.1 tpm'], + [-123.45, '< 0.1 tpm'], + [-1234.56, '< 0.1 tpm'], + [-1234567.89, '< 0.1 tpm'], + ])( + 'displays the correct label when the number is negative and has decimal part', + (value, formattedValue) => { + expect(asTransactionRate(value)).toBe(formattedValue); + } + ); + }); + + describe('asMilliseconds', () => { + it('converts to formatted decimal milliseconds', () => { + expect(asMillisecondDuration(0)).toEqual('0 ms'); + }); + + it('formats correctly with undefined values', () => { + expect(asMillisecondDuration(undefined)).toEqual('N/A'); + }); + }); + + describe('formatDurationFromTimeUnitChar', () => { + it('Convert "s" to "secs".', () => { + expect(formatDurationFromTimeUnitChar(30, 's')).toEqual('30 secs'); + }); + it('Convert "s" to "sec."', () => { + expect(formatDurationFromTimeUnitChar(1, 's')).toEqual('1 sec'); + }); + + it('Convert "m" to "mins".', () => { + expect(formatDurationFromTimeUnitChar(10, 'm')).toEqual('10 mins'); + }); + + it('Convert "m" to "min."', () => { + expect(formatDurationFromTimeUnitChar(1, 'm')).toEqual('1 min'); + }); + + it('Convert "h" to "hrs."', () => { + expect(formatDurationFromTimeUnitChar(5, 'h')).toEqual('5 hrs'); + }); + + it('Convert "h" to "hr"', () => { + expect(formatDurationFromTimeUnitChar(1, 'h')).toEqual('1 hr'); + }); + + it('Convert "d" to "days"', () => { + expect(formatDurationFromTimeUnitChar(2, 'd')).toEqual('2 days'); + }); + + it('Convert "d" to "day"', () => { + expect(formatDurationFromTimeUnitChar(1, 'd')).toEqual('1 day'); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/common/utils/formatters/duration.ts b/x-pack/plugins/exploratory_view/common/utils/formatters/duration.ts new file mode 100644 index 0000000000000..6b202b190bb07 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/formatters/duration.ts @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { memoize } from 'lodash'; +import { NOT_AVAILABLE_LABEL } from '../../i18n'; +import { asDecimalOrInteger, asInteger, asDecimal } from './formatters'; +import { TimeUnit } from './datetime'; +import { Maybe } from '../../typings'; +import { isFiniteNumber } from '../is_finite_number'; + +interface FormatterOptions { + defaultValue?: string; + extended?: boolean; +} + +type DurationTimeUnit = TimeUnit | 'microseconds'; + +interface ConvertedDuration { + value: string; + unit?: string; + formatted: string; +} + +export type TimeFormatter = (value: Maybe, options?: FormatterOptions) => ConvertedDuration; + +type TimeFormatterBuilder = (max: number) => TimeFormatter; + +function getUnitLabelAndConvertedValue(unitKey: DurationTimeUnit, value: number) { + switch (unitKey) { + case 'hours': { + return { + unitLabel: i18n.translate('xpack.exploratoryView.formatters.hoursTimeUnitLabel', { + defaultMessage: 'h', + }), + unitLabelExtended: i18n.translate( + 'xpack.exploratoryView.formatters.hoursTimeUnitLabelExtended', + { + defaultMessage: 'hours', + } + ), + convertedValue: asDecimalOrInteger(moment.duration(value / 1000).asHours()), + }; + } + case 'minutes': { + return { + unitLabel: i18n.translate('xpack.exploratoryView.formatters.minutesTimeUnitLabel', { + defaultMessage: 'min', + }), + unitLabelExtended: i18n.translate( + 'xpack.exploratoryView.formatters.minutesTimeUnitLabelExtended', + { + defaultMessage: 'minutes', + } + ), + convertedValue: asDecimalOrInteger(moment.duration(value / 1000).asMinutes()), + }; + } + case 'seconds': { + return { + unitLabel: i18n.translate('xpack.exploratoryView.formatters.secondsTimeUnitLabel', { + defaultMessage: 's', + }), + unitLabelExtended: i18n.translate( + 'xpack.exploratoryView.formatters.secondsTimeUnitLabelExtended', + { + defaultMessage: 'seconds', + } + ), + convertedValue: asDecimalOrInteger(moment.duration(value / 1000).asSeconds()), + }; + } + case 'milliseconds': { + return { + unitLabel: i18n.translate('xpack.exploratoryView.formatters.millisTimeUnitLabel', { + defaultMessage: 'ms', + }), + unitLabelExtended: i18n.translate( + 'xpack.exploratoryView.formatters.millisTimeUnitLabelExtended', + { + defaultMessage: 'milliseconds', + } + ), + convertedValue: asDecimalOrInteger(moment.duration(value / 1000).asMilliseconds()), + }; + } + case 'microseconds': { + return { + unitLabel: i18n.translate('xpack.exploratoryView.formatters.microsTimeUnitLabel', { + defaultMessage: 'μs', + }), + unitLabelExtended: i18n.translate( + 'xpack.exploratoryView.formatters.microsTimeUnitLabelExtended', + { + defaultMessage: 'microseconds', + } + ), + convertedValue: asInteger(value), + }; + } + } +} + +/** + * Converts a microseconds value into the unit defined. + */ +export function convertTo({ + unit, + microseconds, + defaultValue = NOT_AVAILABLE_LABEL, + extended, +}: { + unit: DurationTimeUnit; + microseconds: Maybe; + defaultValue?: string; + extended?: boolean; +}): ConvertedDuration { + if (!isFiniteNumber(microseconds)) { + return { value: defaultValue, formatted: defaultValue }; + } + + const { convertedValue, unitLabel, unitLabelExtended } = getUnitLabelAndConvertedValue( + unit, + microseconds + ); + + const label = extended ? unitLabelExtended : unitLabel; + + return { + value: convertedValue, + unit: unitLabel, + formatted: `${convertedValue} ${label}`, + }; +} + +export const toMicroseconds = (value: number, timeUnit: TimeUnit) => + moment.duration(value, timeUnit).asMilliseconds() * 1000; + +function getDurationUnitKey(max: number): DurationTimeUnit { + if (max > toMicroseconds(10, 'hours')) { + return 'hours'; + } + if (max > toMicroseconds(10, 'minutes')) { + return 'minutes'; + } + if (max > toMicroseconds(10, 'seconds')) { + return 'seconds'; + } + if (max > toMicroseconds(1, 'milliseconds')) { + return 'milliseconds'; + } + return 'microseconds'; +} + +export const getDurationFormatter: TimeFormatterBuilder = memoize((max: number) => { + const unit = getDurationUnitKey(max); + return (value, { defaultValue, extended }: FormatterOptions = {}) => { + return convertTo({ unit, microseconds: value, defaultValue, extended }); + }; +}); + +export function asTransactionRate(value: Maybe) { + if (!isFiniteNumber(value)) { + return NOT_AVAILABLE_LABEL; + } + + let displayedValue: string; + + if (value === 0) { + displayedValue = '0'; + } else if (value <= 0.1) { + displayedValue = '< 0.1'; + } else { + displayedValue = asDecimal(value); + } + + return i18n.translate('xpack.exploratoryView.transactionRateLabel', { + defaultMessage: `{value} tpm`, + values: { + value: displayedValue, + }, + }); +} + +/** + * Converts value and returns it formatted - 00 unit + */ +export function asDuration( + value: Maybe, + { defaultValue = NOT_AVAILABLE_LABEL, extended }: FormatterOptions = {} +) { + if (!isFiniteNumber(value)) { + return defaultValue; + } + + const formatter = getDurationFormatter(value); + return formatter(value, { defaultValue, extended }).formatted; +} + +export type AsDuration = typeof asDuration; + +/** + * Convert a microsecond value to decimal milliseconds. Normally we use + * `asDuration`, but this is used in places like tables where we always want + * the same units. + */ +export function asMillisecondDuration(value: Maybe) { + return convertTo({ + unit: 'milliseconds', + microseconds: value, + }).formatted; +} + +export type TimeUnitChar = 's' | 'm' | 'h' | 'd'; + +export const formatDurationFromTimeUnitChar = (time: number, unit: TimeUnitChar): string => { + const sForPlural = time !== 0 && time > 1 ? 's' : ''; // Negative values are not taken into account + switch (unit) { + case 's': + return `${time} sec${sForPlural}`; + case 'm': + return `${time} min${sForPlural}`; + case 'h': + return `${time} hr${sForPlural}`; + case 'd': + return `${time} day${sForPlural}`; + default: + return `${time} ${unit}`; + } +}; diff --git a/x-pack/plugins/exploratory_view/common/utils/formatters/formatters.test.ts b/x-pack/plugins/exploratory_view/common/utils/formatters/formatters.test.ts new file mode 100644 index 0000000000000..397eaae04b51a --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/formatters/formatters.test.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { asDecimal, asInteger, asPercent, asDecimalOrInteger } from './formatters'; + +describe('formatters', () => { + describe('asDecimal', () => { + it.each([ + [Infinity, 'N/A'], + [-Infinity, 'N/A'], + [null, 'N/A'], + [undefined, 'N/A'], + [NaN, 'N/A'], + ])( + 'displays the not available label when the number is not finite', + (value, formattedValue) => { + expect(asDecimal(value)).toBe(formattedValue); + } + ); + + it.each([ + [0, '0.0'], + [0.005, '0.0'], + [1.23, '1.2'], + [12.34, '12.3'], + [123.45, '123.5'], + [1234.56, '1,234.6'], + [1234567.89, '1,234,567.9'], + ])('displays the correct label when the number is finite', (value, formattedValue) => { + expect(asDecimal(value)).toBe(formattedValue); + }); + }); + + describe('asInteger', () => { + it.each([ + [Infinity, 'N/A'], + [-Infinity, 'N/A'], + [null, 'N/A'], + [undefined, 'N/A'], + [NaN, 'N/A'], + ])( + 'displays the not available label when the number is not finite', + (value, formattedValue) => { + expect(asInteger(value)).toBe(formattedValue); + } + ); + + it.each([ + [0, '0'], + [0.005, '0'], + [1.23, '1'], + [12.34, '12'], + [123.45, '123'], + [1234.56, '1,235'], + [1234567.89, '1,234,568'], + ])('displays the correct label when the number is finite', (value, formattedValue) => { + expect(asInteger(value)).toBe(formattedValue); + }); + }); + + describe('asPercent', () => { + it('formats as integer when number is above 10', () => { + expect(asPercent(3725, 10000, 'n/a')).toEqual('37%'); + }); + + it('adds a decimal when value is below 10', () => { + expect(asPercent(0.092, 1)).toEqual('9.2%'); + }); + + it('formats when numerator is 0', () => { + expect(asPercent(0, 1, 'n/a')).toEqual('0%'); + }); + + it('returns fallback when denominator is undefined', () => { + expect(asPercent(3725, undefined, 'n/a')).toEqual('n/a'); + }); + + it('returns fallback when denominator is 0 ', () => { + expect(asPercent(3725, 0, 'n/a')).toEqual('n/a'); + }); + + it('returns fallback when numerator or denominator is NaN', () => { + expect(asPercent(3725, NaN, 'n/a')).toEqual('n/a'); + expect(asPercent(NaN, 10000, 'n/a')).toEqual('n/a'); + }); + }); + + describe('asDecimalOrInteger', () => { + it('formats as integer when number equals to 0 ', () => { + expect(asDecimalOrInteger(0)).toEqual('0'); + }); + + it('formats as integer when number is above or equals 10 ', () => { + expect(asDecimalOrInteger(10.123)).toEqual('10'); + expect(asDecimalOrInteger(15.123)).toEqual('15'); + }); + + it.each([ + [0.25435632645, '0.3'], + [1, '1.0'], + [3.374329704990765, '3.4'], + [5, '5.0'], + [9, '9.0'], + ])('formats as decimal when number is below 10 ', (value, formattedValue) => { + expect(asDecimalOrInteger(value)).toBe(formattedValue); + }); + + it.each([ + [-0.123, '-0.1'], + [-1.234, '-1.2'], + [-9.876, '-9.9'], + ])( + 'formats as decimal when number is negative and below 10 in absolute value', + (value, formattedValue) => { + expect(asDecimalOrInteger(value)).toEqual(formattedValue); + } + ); + + it.each([ + [-12.34, '-12'], + [-123.45, '-123'], + [-1234.56, '-1,235'], + [-12345.67, '-12,346'], + [-12345678.9, '-12,345,679'], + ])( + 'formats as integer when number is negative and above or equals 10 in absolute value', + (value, formattedValue) => { + expect(asDecimalOrInteger(value)).toEqual(formattedValue); + } + ); + }); +}); diff --git a/x-pack/plugins/exploratory_view/common/utils/formatters/formatters.ts b/x-pack/plugins/exploratory_view/common/utils/formatters/formatters.ts new file mode 100644 index 0000000000000..05d8d2638ba7b --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/formatters/formatters.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import numeral from '@elastic/numeral'; +import { Maybe } from '../../typings'; +import { NOT_AVAILABLE_LABEL } from '../../i18n'; +import { isFiniteNumber } from '../is_finite_number'; + +export function asDecimal(value?: number | null) { + if (!isFiniteNumber(value)) { + return NOT_AVAILABLE_LABEL; + } + + return numeral(value).format('0,0.0'); +} + +export function asInteger(value?: number | null) { + if (!isFiniteNumber(value)) { + return NOT_AVAILABLE_LABEL; + } + + return numeral(value).format('0,0'); +} + +export function asPercent( + numerator: Maybe, + denominator: number | undefined, + fallbackResult = NOT_AVAILABLE_LABEL +) { + if (!denominator || !isFiniteNumber(numerator)) { + return fallbackResult; + } + + const decimal = numerator / denominator; + + // 33.2 => 33% + // 3.32 => 3.3% + // 0 => 0% + if (Math.abs(decimal) >= 0.1 || decimal === 0) { + return numeral(decimal).format('0%'); + } + + return numeral(decimal).format('0.0%'); +} + +export type AsPercent = typeof asPercent; + +export function asDecimalOrInteger(value: number) { + // exact 0 or above 10 should not have decimal + if (value === 0 || Math.abs(value) >= 10) { + return asInteger(value); + } + return asDecimal(value); +} diff --git a/x-pack/plugins/exploratory_view/common/utils/formatters/index.ts b/x-pack/plugins/exploratory_view/common/utils/formatters/index.ts new file mode 100644 index 0000000000000..1a431867308b6 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/formatters/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './formatters'; +export * from './datetime'; +export * from './duration'; +export * from './size'; diff --git a/x-pack/plugins/exploratory_view/common/utils/formatters/size.test.ts b/x-pack/plugins/exploratory_view/common/utils/formatters/size.test.ts new file mode 100644 index 0000000000000..a71617151c0db --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/formatters/size.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getFixedByteFormatter, asDynamicBytes } from './size'; + +describe('size formatters', () => { + describe('byte formatting', () => { + const bytes = 10; + const kb = 1000 + 1; + const mb = 1e6 + 1; + const gb = 1e9 + 1; + const tb = 1e12 + 1; + + test('dynamic', () => { + expect(asDynamicBytes(bytes)).toEqual('10.0 B'); + expect(asDynamicBytes(kb)).toEqual('1.0 KB'); + expect(asDynamicBytes(mb)).toEqual('1.0 MB'); + expect(asDynamicBytes(gb)).toEqual('1.0 GB'); + expect(asDynamicBytes(tb)).toEqual('1.0 TB'); + expect(asDynamicBytes(null)).toEqual(''); + expect(asDynamicBytes(NaN)).toEqual(''); + }); + + describe('fixed', () => { + test('in bytes', () => { + const formatInBytes = getFixedByteFormatter(bytes); + expect(formatInBytes(bytes)).toEqual('10.0 B'); + expect(formatInBytes(kb)).toEqual('1,001.0 B'); + expect(formatInBytes(mb)).toEqual('1,000,001.0 B'); + expect(formatInBytes(gb)).toEqual('1,000,000,001.0 B'); + expect(formatInBytes(tb)).toEqual('1,000,000,000,001.0 B'); + expect(formatInBytes(null)).toEqual(''); + expect(formatInBytes(NaN)).toEqual(''); + }); + + test('in kb', () => { + const formatInKB = getFixedByteFormatter(kb); + expect(formatInKB(bytes)).toEqual('0.0 KB'); + expect(formatInKB(kb)).toEqual('1.0 KB'); + expect(formatInKB(mb)).toEqual('1,000.0 KB'); + expect(formatInKB(gb)).toEqual('1,000,000.0 KB'); + expect(formatInKB(tb)).toEqual('1,000,000,000.0 KB'); + }); + + test('in mb', () => { + const formatInMB = getFixedByteFormatter(mb); + expect(formatInMB(bytes)).toEqual('0.0 MB'); + expect(formatInMB(kb)).toEqual('0.0 MB'); + expect(formatInMB(mb)).toEqual('1.0 MB'); + expect(formatInMB(gb)).toEqual('1,000.0 MB'); + expect(formatInMB(tb)).toEqual('1,000,000.0 MB'); + expect(formatInMB(null)).toEqual(''); + expect(formatInMB(NaN)).toEqual(''); + }); + + test('in gb', () => { + const formatInGB = getFixedByteFormatter(gb); + expect(formatInGB(bytes)).toEqual('1e-8 GB'); + expect(formatInGB(kb)).toEqual('0.0 GB'); + expect(formatInGB(mb)).toEqual('0.0 GB'); + expect(formatInGB(gb)).toEqual('1.0 GB'); + expect(formatInGB(tb)).toEqual('1,000.0 GB'); + expect(formatInGB(null)).toEqual(''); + expect(formatInGB(NaN)).toEqual(''); + }); + + test('in tb', () => { + const formatInTB = getFixedByteFormatter(tb); + expect(formatInTB(bytes)).toEqual('1e-11 TB'); + expect(formatInTB(kb)).toEqual('1.001e-9 TB'); + expect(formatInTB(mb)).toEqual('0.0 TB'); + expect(formatInTB(gb)).toEqual('0.0 TB'); + expect(formatInTB(tb)).toEqual('1.0 TB'); + expect(formatInTB(null)).toEqual(''); + expect(formatInTB(NaN)).toEqual(''); + }); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/common/utils/formatters/size.ts b/x-pack/plugins/exploratory_view/common/utils/formatters/size.ts new file mode 100644 index 0000000000000..ec0b753f1523d --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/formatters/size.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { memoize } from 'lodash'; +import { asDecimal } from './formatters'; +import { Maybe } from '../../typings'; + +function asKilobytes(value: number) { + return `${asDecimal(value / 1000)} KB`; +} + +function asMegabytes(value: number) { + return `${asDecimal(value / 1e6)} MB`; +} + +function asGigabytes(value: number) { + return `${asDecimal(value / 1e9)} GB`; +} + +function asTerabytes(value: number) { + return `${asDecimal(value / 1e12)} TB`; +} + +function asBytes(value: number) { + return `${asDecimal(value)} B`; +} + +const bailIfNumberInvalid = (cb: (val: number) => string) => { + return (val: Maybe) => { + if (val === null || val === undefined || isNaN(val)) { + return ''; + } + return cb(val); + }; +}; + +export const getFixedByteFormatter = memoize((max: number) => { + const formatter = unmemoizedFixedByteFormatter(max); + + return bailIfNumberInvalid(formatter); +}); + +export const asDynamicBytes = bailIfNumberInvalid((value: number) => { + return unmemoizedFixedByteFormatter(value)(value); +}); + +const unmemoizedFixedByteFormatter = (max: number) => { + if (max > 1e12) { + return asTerabytes; + } + + if (max > 1e9) { + return asGigabytes; + } + + if (max > 1e6) { + return asMegabytes; + } + + if (max > 1000) { + return asKilobytes; + } + + return asBytes; +}; diff --git a/x-pack/plugins/exploratory_view/common/utils/get_inspect_response.ts b/x-pack/plugins/exploratory_view/common/utils/get_inspect_response.ts new file mode 100644 index 0000000000000..43d633067f984 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/get_inspect_response.ts @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { KibanaRequest } from '@kbn/core/server'; +import type { RequestStatistics, RequestStatus } from '@kbn/inspector-plugin/common'; +import { InspectResponse } from '../../typings/common'; +import { WrappedElasticsearchClientError } from './unwrap_es_response'; + +/** + * Get statistics to show on inspector tab. + * + * If you're using searchSource (which we're not), this gets populated from + * https://github.com/elastic/kibana/blob/c7d742cb8b8935f3812707a747a139806e4be203/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts + * + * We do most of the same here, but not using searchSource. + */ +function getStats({ + esRequestParams, + esResponse, + kibanaRequest, +}: { + esRequestParams: Record; + esResponse: any; + kibanaRequest: KibanaRequest; +}) { + const stats: RequestStatistics = { + ...(kibanaRequest.query + ? { + kibanaApiQueryParameters: { + label: i18n.translate( + 'xpack.exploratoryView.inspector.stats.kibanaApiQueryParametersLabel', + { + defaultMessage: 'Kibana API query parameters', + } + ), + description: i18n.translate( + 'xpack.exploratoryView.inspector.stats.kibanaApiQueryParametersDescription', + { + defaultMessage: + 'The query parameters used in the Kibana API request that initiated the Elasticsearch request.', + } + ), + value: JSON.stringify(kibanaRequest.query, null, 2), + }, + } + : {}), + kibanaApiRoute: { + label: i18n.translate('xpack.exploratoryView.inspector.stats.kibanaApiRouteLabel', { + defaultMessage: 'Kibana API route', + }), + description: i18n.translate( + 'xpack.exploratoryView.inspector.stats.kibanaApiRouteDescription', + { + defaultMessage: + 'The route of the Kibana API request that initiated the Elasticsearch request.', + } + ), + value: `${kibanaRequest.route.method.toUpperCase()} ${kibanaRequest.route.path}`, + }, + indexPattern: { + label: i18n.translate('xpack.exploratoryView.inspector.stats.dataViewLabel', { + defaultMessage: 'Data view', + }), + value: esRequestParams.index, + description: i18n.translate('xpack.exploratoryView.inspector.stats.dataViewDescription', { + defaultMessage: 'The data view that connected to the Elasticsearch indices.', + }), + }, + }; + + if (esResponse?.hits?.hits) { + stats.hits = { + label: i18n.translate('xpack.exploratoryView.inspector.stats.hitsLabel', { + defaultMessage: 'Hits', + }), + value: `${esResponse.hits.hits.length}`, + description: i18n.translate('xpack.exploratoryView.inspector.stats.hitsDescription', { + defaultMessage: 'The number of documents returned by the query.', + }), + }; + } + + if (esResponse?.took) { + stats.queryTime = { + label: i18n.translate('xpack.exploratoryView.inspector.stats.queryTimeLabel', { + defaultMessage: 'Query time', + }), + value: i18n.translate('xpack.exploratoryView.inspector.stats.queryTimeValue', { + defaultMessage: '{queryTime}ms', + values: { queryTime: esResponse.took }, + }), + description: i18n.translate('xpack.exploratoryView.inspector.stats.queryTimeDescription', { + defaultMessage: + 'The time it took to process the query. ' + + 'Does not include the time to send the request or parse it in the browser.', + }), + }; + } + + if (esResponse?.hits?.total !== undefined) { + let hitsTotalValue; + + if (typeof esResponse.hits.total === 'number') { + hitsTotalValue = esResponse.hits.total; + } else { + const total = esResponse.hits.total as { + relation: string; + value: number; + }; + hitsTotalValue = total.relation === 'eq' ? `${total.value}` : `> ${total.value}`; + } + + stats.hitsTotal = { + label: i18n.translate('xpack.exploratoryView.inspector.stats.hitsTotalLabel', { + defaultMessage: 'Hits (total)', + }), + value: hitsTotalValue, + description: i18n.translate('xpack.exploratoryView.inspector.stats.hitsTotalDescription', { + defaultMessage: 'The number of documents that match the query.', + }), + }; + } + return stats; +} + +/** + * Create a formatted response to be sent in the _inspect key for use in the + * inspector. + */ +export function getInspectResponse({ + esError, + esRequestParams, + esRequestStatus, + esResponse, + kibanaRequest, + operationName, + startTime, +}: { + esError: WrappedElasticsearchClientError | null; + esRequestParams: Record; + esRequestStatus: RequestStatus; + esResponse: any; + kibanaRequest: KibanaRequest; + operationName: string; + startTime: number; +}): InspectResponse[0] { + const id = `${operationName} (${kibanaRequest.route.path})`; + + return { + id, + json: esRequestParams.body ?? esRequestParams, + name: id, + response: { + json: esError ? esError.originalError : esResponse, + }, + startTime, + stats: getStats({ esRequestParams, esResponse, kibanaRequest }), + status: esRequestStatus, + }; +} diff --git a/x-pack/plugins/exploratory_view/common/utils/is_finite_number.ts b/x-pack/plugins/exploratory_view/common/utils/is_finite_number.ts new file mode 100644 index 0000000000000..e5c9af80c7d69 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/is_finite_number.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isFinite } from 'lodash'; + +// _.isNumber() returns true for NaN, _.isFinite() does not refine +export function isFiniteNumber(value: any): value is number { + return isFinite(value); +} diff --git a/x-pack/plugins/exploratory_view/common/utils/join_by_key/index.test.ts b/x-pack/plugins/exploratory_view/common/utils/join_by_key/index.test.ts new file mode 100644 index 0000000000000..5938d952cb42f --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/join_by_key/index.test.ts @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { joinByKey } from '.'; + +describe('joinByKey', () => { + it('joins by a string key', () => { + const joined = joinByKey( + [ + { + serviceName: 'opbeans-node', + avg: 10, + }, + { + serviceName: 'opbeans-node', + count: 12, + }, + { + serviceName: 'opbeans-java', + avg: 11, + }, + { + serviceName: 'opbeans-java', + p95: 18, + }, + ], + 'serviceName' + ); + + expect(joined.length).toBe(2); + + expect(joined).toEqual([ + { + serviceName: 'opbeans-node', + avg: 10, + count: 12, + }, + { + serviceName: 'opbeans-java', + avg: 11, + p95: 18, + }, + ]); + }); + + it('joins by a record key', () => { + const joined = joinByKey( + [ + { + key: { + serviceName: 'opbeans-node', + transactionName: '/api/opbeans-node', + }, + avg: 10, + }, + { + key: { + serviceName: 'opbeans-node', + transactionName: '/api/opbeans-node', + }, + count: 12, + }, + { + key: { + serviceName: 'opbeans-java', + transactionName: '/api/opbeans-java', + }, + avg: 11, + }, + { + key: { + serviceName: 'opbeans-java', + transactionName: '/api/opbeans-java', + }, + p95: 18, + }, + ], + 'key' + ); + + expect(joined.length).toBe(2); + + expect(joined).toEqual([ + { + key: { + serviceName: 'opbeans-node', + transactionName: '/api/opbeans-node', + }, + avg: 10, + count: 12, + }, + { + key: { + serviceName: 'opbeans-java', + transactionName: '/api/opbeans-java', + }, + avg: 11, + p95: 18, + }, + ]); + }); + + it('uses the custom merge fn to replace items', () => { + const joined = joinByKey( + [ + { + serviceName: 'opbeans-java', + values: ['a'], + }, + { + serviceName: 'opbeans-node', + values: ['a'], + }, + { + serviceName: 'opbeans-node', + values: ['b'], + }, + { + serviceName: 'opbeans-node', + values: ['c'], + }, + ], + 'serviceName', + (a, b) => ({ + ...a, + ...b, + values: a.values.concat(b.values), + }) + ); + + expect(joined.find((item) => item.serviceName === 'opbeans-node')?.values).toEqual([ + 'a', + 'b', + 'c', + ]); + }); + + it('deeply merges objects', () => { + const joined = joinByKey( + [ + { + serviceName: 'opbeans-node', + properties: { + foo: '', + }, + }, + { + serviceName: 'opbeans-node', + properties: { + bar: '', + }, + }, + ], + 'serviceName' + ); + + expect(joined[0]).toEqual({ + serviceName: 'opbeans-node', + properties: { + foo: '', + bar: '', + }, + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/common/utils/join_by_key/index.ts b/x-pack/plugins/exploratory_view/common/utils/join_by_key/index.ts new file mode 100644 index 0000000000000..e03fe6af8c2f0 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/join_by_key/index.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { UnionToIntersection, ValuesType } from 'utility-types'; +import { isEqual, pull, merge, castArray } from 'lodash'; + +/** + * Joins a list of records by a given key. Key can be any type of value, from + * strings to plain objects, as long as it is present in all records. `isEqual` + * is used for comparing keys. + * + * UnionToIntersection is needed to get all keys of union types, see below for + * example. + * + const agentNames = [{ serviceName: '', agentName: '' }]; + const transactionRates = [{ serviceName: '', transactionsPerMinute: 1 }]; + const flattened = joinByKey( + [...agentNames, ...transactionRates], + 'serviceName' + ); +*/ + +type JoinedReturnType, U extends UnionToIntersection> = Array< + Partial & { + [k in keyof T]: T[k]; + } +>; + +type ArrayOrSingle = T | T[]; + +export function joinByKey< + T extends Record, + U extends UnionToIntersection, + V extends ArrayOrSingle +>(items: T[], key: V): JoinedReturnType; + +export function joinByKey< + T extends Record, + U extends UnionToIntersection, + V extends ArrayOrSingle, + W extends JoinedReturnType, + X extends (a: T, b: T) => ValuesType +>(items: T[], key: V, mergeFn: X): W; + +export function joinByKey( + items: Array>, + key: string | string[], + mergeFn: Function = (a: Record, b: Record) => merge({}, a, b) +) { + const keys = castArray(key); + return items.reduce>>((prev, current) => { + let item = prev.find((prevItem) => keys.every((k) => isEqual(prevItem[k], current[k]))); + + if (!item) { + item = { ...current }; + prev.push(item); + } else { + pull(prev, item).push(mergeFn(item, current)); + } + + return prev; + }, []); +} diff --git a/x-pack/plugins/exploratory_view/common/utils/maybe.ts b/x-pack/plugins/exploratory_view/common/utils/maybe.ts new file mode 100644 index 0000000000000..f73dbe09d6ad4 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/maybe.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function maybe(value: T): T | null | undefined { + return value; +} diff --git a/x-pack/plugins/exploratory_view/common/utils/pick_keys.ts b/x-pack/plugins/exploratory_view/common/utils/pick_keys.ts new file mode 100644 index 0000000000000..fe45e9a0e42c8 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/pick_keys.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { pick } from 'lodash'; + +export function pickKeys(obj: T, ...keys: K[]) { + return pick(obj, keys) as Pick; +} diff --git a/x-pack/plugins/exploratory_view/common/utils/unwrap_es_response.ts b/x-pack/plugins/exploratory_view/common/utils/unwrap_es_response.ts new file mode 100644 index 0000000000000..1448a0fe027c8 --- /dev/null +++ b/x-pack/plugins/exploratory_view/common/utils/unwrap_es_response.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 { errors } from '@elastic/elasticsearch'; +import { inspect } from 'util'; + +export class WrappedElasticsearchClientError extends Error { + originalError: errors.ElasticsearchClientError; + constructor(originalError: errors.ElasticsearchClientError) { + super(originalError.message); + + const stack = this.stack; + + this.originalError = originalError; + + if (originalError instanceof errors.ResponseError) { + // make sure ES response body is visible when logged to the console + // @ts-expect-error + this.stack = { + valueOf() { + const value = stack?.valueOf() ?? ''; + return value; + }, + toString() { + const value = + stack?.toString() + + `\nResponse: ${inspect(originalError.meta.body, { depth: null })}\n`; + return value; + }, + }; + } + } +} + +export function unwrapEsResponse>( + responsePromise: T +): Promise['body']> { + return responsePromise + .then((res) => res.body) + .catch((err) => { + // make sure stacktrace is relative to where client was called + throw new WrappedElasticsearchClientError(err); + }); +} diff --git a/x-pack/plugins/exploratory_view/e2e/README.md b/x-pack/plugins/exploratory_view/e2e/README.md new file mode 100644 index 0000000000000..bd6161755d2db --- /dev/null +++ b/x-pack/plugins/exploratory_view/e2e/README.md @@ -0,0 +1,15 @@ +## How to run these tests + +These tests rely on the Kibana functional test runner. There is a Kibana config in this directory, and a dedicated +script for standing up the test server. + +### Start the server + +From `~/x-pack/plugins/exploratory_view/scripts`, run `node e2e.js --server`. Wait for the server to startup. It will provide you +with an example run command when it finishes. + +### Run the tests + +From this directory, `~/x-pack/plugins/exploratory_view/e2e`, you can now run `node ../../../../scripts/functional_test_runner --config synthetics_run.ts`. + +In addition to the usual flags like `--grep`, you can also specify `--no-headless` in order to view your tests as you debug/develop. diff --git a/x-pack/plugins/exploratory_view/e2e/journeys/exploratory_view.ts b/x-pack/plugins/exploratory_view/e2e/journeys/exploratory_view.ts new file mode 100644 index 0000000000000..877c13e93c373 --- /dev/null +++ b/x-pack/plugins/exploratory_view/e2e/journeys/exploratory_view.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 { journey, step, before } from '@elastic/synthetics'; +import { recordVideo } from '../record_video'; +import { createExploratoryViewUrl } from '../../public/components/shared/exploratory_view/configurations/exploratory_view_url'; +import { loginToKibana, TIMEOUT_60_SEC, waitForLoadingToFinish } from '../utils'; + +journey('Exploratory view', async ({ page, params }) => { + recordVideo(page); + + before(async () => { + await waitForLoadingToFinish({ page }); + }); + + const expUrl = createExploratoryViewUrl({ + reportType: 'kpi-over-time', + allSeries: [ + { + name: 'Elastic page views', + time: { + from: '2021-01-18T12:20:01.682Z', + to: '2021-01-18T12:25:27.484Z', + }, + selectedMetricField: '___records___', + reportDefinitions: { 'service.name': [] }, + dataType: 'ux', + }, + ], + }); + + const baseUrl = `${params.kibanaUrl}${expUrl}`; + + step('Go to Exploratory view', async () => { + await page.goto(baseUrl, { + waitUntil: 'networkidle', + }); + await loginToKibana({ + page, + user: { username: 'elastic', password: 'changeme' }, + }); + }); + + step('renders as expected', async () => { + await Promise.all([page.waitForNavigation(TIMEOUT_60_SEC), page.click('text=Explore data')]); + await page.click('text=User experience (RUM)'); + await page.click('[aria-label="Toggle series information"] >> text=Page views', TIMEOUT_60_SEC); + await page.click('[aria-label="Edit series"]', TIMEOUT_60_SEC); + await page.click('button:has-text("No breakdown")'); + await page.click('button[role="option"]:has-text("Operating system")', TIMEOUT_60_SEC); + await page.click('button:has-text("Apply changes")'); + + await page.click('text=Chrome OS'); + await page.click('text=iOS'); + await page.click('text=iOS'); + await page.click('text=Chrome OS'); + await page.click('text=Ubuntu'); + await page.click('text=Android'); + await page.click('text=Linux'); + await page.click('text=Mac OS X'); + await page.click('text=Windows'); + await page.click('h1:has-text("Explore data")'); + }); + + step('Edit and change the series to distribution', async () => { + await page.click('[aria-label="View series actions"]'); + await page.click('[aria-label="Remove series"]'); + await page.click('button:has-text("KPI over time")'); + await page.click('button[role="option"]:has-text("Performance distribution")'); + await page.click('button:has-text("Add series")'); + await page.click('button:has-text("Select data type")'); + await page.click('button:has-text("User experience (RUM)")'); + await page.click('button:has-text("Select report metric")'); + await page.click('button:has-text("Page load time")'); + await page.click('.euiComboBox__inputWrap'); + await page.click('[aria-label="Date quick select"]'); + await page.click('text=Last 1 year'); + await page.click('[aria-label="Date quick select"]'); + await page.click('[aria-label="Time value"]'); + await page.fill('[aria-label="Time value"]', '010'); + await page.selectOption('[aria-label="Time unit"]', 'y'); + + await page.click('div[role="dialog"] button:has-text("Apply")'); + await page.click('.euiComboBox__inputWrap'); + await page.click('button[role="option"]:has-text("elastic-co-frontend")'); + await page.click('button:has-text("Apply changes")'); + await page.click('text=ux-series-1'); + await page.click('text=User experience (RUM)'); + await page.click('text=Page load time'); + await page.click('text=Pages loaded'); + await page.click('button:has-text("95th")'); + await page.click('button:has-text("90th")'); + await page.click('button:has-text("99th")'); + await page.click('[aria-label="Edit series"]'); + await page.click('button:has-text("No breakdown")'); + await page.click('button[role="option"]:has-text("Browser family")'); + await page.click('button:has-text("Apply changes")'); + await page.click('text=Edge'); + await page.click('text=Opera'); + await page.click('text=Safari'); + await page.click('text=HeadlessChrome'); + await page.click('[aria-label="Firefox; Activate to hide series in graph"]'); + }); +}); diff --git a/x-pack/plugins/exploratory_view/e2e/journeys/index.ts b/x-pack/plugins/exploratory_view/e2e/journeys/index.ts new file mode 100644 index 0000000000000..3f0bc8128434c --- /dev/null +++ b/x-pack/plugins/exploratory_view/e2e/journeys/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// export * from './exploratory_view'; +export * from './step_duration.journey'; +// export * from './single_metric.journey'; diff --git a/x-pack/plugins/exploratory_view/e2e/journeys/single_metric.journey.ts b/x-pack/plugins/exploratory_view/e2e/journeys/single_metric.journey.ts new file mode 100644 index 0000000000000..6fcdb71ccffa2 --- /dev/null +++ b/x-pack/plugins/exploratory_view/e2e/journeys/single_metric.journey.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { journey, step, before } from '@elastic/synthetics'; +import { recordVideo } from '../record_video'; +import { createExploratoryViewUrl } from '../../public/components/shared/exploratory_view/configurations/exploratory_view_url'; +import { loginToKibana, TIMEOUT_60_SEC, waitForLoadingToFinish } from '../utils'; + +journey('SingleMetric', async ({ page, params }) => { + recordVideo(page); + + before(async () => { + await waitForLoadingToFinish({ page }); + }); + + const expUrl = createExploratoryViewUrl({ + reportType: 'single-metric', + allSeries: [ + { + dataType: 'synthetics', + time: { + from: 'now-1y/d', + to: 'now', + }, + name: 'synthetics-series-1', + selectedMetricField: 'monitor_availability', + reportDefinitions: { + 'monitor.name': ['test-monitor - inline'], + 'url.full': ['https://www.elastic.co/'], + }, + }, + ], + }); + + const baseUrl = `${params.kibanaUrl}${expUrl}`; + + step('Go to Exploratory view', async () => { + await page.goto(baseUrl, { + waitUntil: 'networkidle', + }); + await loginToKibana({ + page, + user: { username: 'elastic', password: 'changeme' }, + }); + }); + + step('Open exploratory view with single metric', async () => { + await Promise.all([ + page.waitForNavigation(TIMEOUT_60_SEC), + page.click('text=Explore data', TIMEOUT_60_SEC), + ]); + + await waitForLoadingToFinish({ page }); + + await page.click('text=0.0%', TIMEOUT_60_SEC); + await page.click('text=0.0%Availability'); + await page.click( + 'text=Explore data Last Updated: a few seconds agoRefreshHide chart0.0%AvailabilityRep' + ); + }); +}); diff --git a/x-pack/plugins/exploratory_view/e2e/journeys/step_duration.journey.ts b/x-pack/plugins/exploratory_view/e2e/journeys/step_duration.journey.ts new file mode 100644 index 0000000000000..706de17fcc661 --- /dev/null +++ b/x-pack/plugins/exploratory_view/e2e/journeys/step_duration.journey.ts @@ -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 { journey, step, before, after } from '@elastic/synthetics'; +import moment from 'moment'; +import { recordVideo } from '../record_video'; +import { createExploratoryViewUrl } from '../../public/components/shared/exploratory_view/configurations/exploratory_view_url'; +import { loginToKibana, TIMEOUT_60_SEC, waitForLoadingToFinish } from '../utils'; + +journey('Exploratory view', async ({ page, params }) => { + recordVideo(page); + + before(async () => { + await waitForLoadingToFinish({ page }); + }); + + after(async () => { + // eslint-disable-next-line no-console + console.log(await page.video()?.path()); + }); + + const expUrl = createExploratoryViewUrl({ + reportType: 'kpi-over-time', + allSeries: [ + { + dataType: 'uptime', + time: { + from: moment().subtract(10, 'y').toISOString(), + to: moment().toISOString(), + }, + name: 'synthetics-series-1', + breakdown: 'monitor.type', + selectedMetricField: 'monitor.duration.us', + reportDefinitions: { + 'url.full': ['ALL_VALUES'], + }, + }, + ], + }); + + const baseUrl = `${params.kibanaUrl}${expUrl}`; + + step('Go to Exploratory view', async () => { + await page.goto(baseUrl, { + waitUntil: 'networkidle', + }); + await loginToKibana({ + page, + user: { username: 'elastic', password: 'changeme' }, + }); + }); + + step('Open exploratory view with monitor duration', async () => { + await page.waitForNavigation(TIMEOUT_60_SEC); + + await waitForLoadingToFinish({ page }); + await page.click('text=browser', TIMEOUT_60_SEC); + await page.click('text=http'); + await page.click('[aria-label="Remove report metric"]'); + await page.click('button:has-text("Select report metric")'); + await page.click('button:has-text("Step duration")'); + await page.click('text=Select an option: Monitor type, is selectedMonitor type >> button'); + await page.click('button[role="option"]:has-text("Step name")'); + await page.click('.euiComboBox__inputWrap'); + await page.click( + 'text=Search Monitor nameCombo box. Selected. Combo box input. Search Monitor name. Ty' + ); + await page.click('button[role="option"]:has-text("test-monitor - inline")'); + await page.click('button:has-text("Apply changes")'); + + await waitForLoadingToFinish({ page }); + + await page.click('[aria-label="series color: #54b399"]'); + await page.click('[aria-label="series color: #6092c0"]'); + await page.click('[aria-label="series color: #d36086"] path'); + await page.click('[aria-label="series color: #9170b8"]'); + await page.click('[aria-label="series color: #ca8eae"]'); + await page.click('[aria-label="series color: #d6bf57"]'); + await page.click('text=load homepage'); + await page.click('text=load homepage'); + await page.click('text=load github'); + await page.click('text=load github'); + await page.click('text=load google'); + await page.click('text=load google'); + await page.click('text=hover over products menu'); + await page.click('text=hover over products menu'); + await page.click('text=load homepage 1'); + await page.click('text=load homepage 1'); + await page.click('text=load homepage 2'); + await page.click('text=load homepage 2'); + }); +}); diff --git a/x-pack/plugins/exploratory_view/e2e/parse_args_params.ts b/x-pack/plugins/exploratory_view/e2e/parse_args_params.ts new file mode 100644 index 0000000000000..41100ba2ec295 --- /dev/null +++ b/x-pack/plugins/exploratory_view/e2e/parse_args_params.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import yargs from 'yargs'; + +const { argv } = yargs(process.argv.slice(2)) + .option('headless', { + default: true, + type: 'boolean', + description: 'Start in headless mode', + }) + .option('bail', { + default: false, + type: 'boolean', + description: 'Pause on error', + }) + .option('watch', { + default: false, + type: 'boolean', + description: 'Runs the server in watch mode, restarting on changes', + }) + .option('grep', { + default: undefined, + type: 'string', + description: 'run only journeys with a name or tags that matches the glob', + }) + .help(); + +export { argv }; diff --git a/x-pack/plugins/exploratory_view/e2e/record_video.ts b/x-pack/plugins/exploratory_view/e2e/record_video.ts new file mode 100644 index 0000000000000..23bcdfb643e72 --- /dev/null +++ b/x-pack/plugins/exploratory_view/e2e/record_video.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import fs from 'fs'; +import Runner from '@elastic/synthetics/dist/core/runner'; +import { after, Page } from '@elastic/synthetics'; + +const SYNTHETICS_RUNNER = Symbol.for('SYNTHETICS_RUNNER'); + +// @ts-ignore +export const runner: Runner = global[SYNTHETICS_RUNNER]; + +export const recordVideo = (page: Page, postfix = '') => { + after(async () => { + try { + const videoFilePath = await page.video()?.path(); + const pathToVideo = videoFilePath?.replace('.journeys/videos/', '').replace('.webm', ''); + const newVideoPath = videoFilePath?.replace( + pathToVideo!, + postfix ? runner.currentJourney!.name + `-${postfix}` : runner.currentJourney!.name + ); + fs.renameSync(videoFilePath!, newVideoPath!); + } catch (e) { + // eslint-disable-next-line no-console + console.log('Error while renaming video file', e); + } + }); +}; diff --git a/x-pack/plugins/exploratory_view/e2e/synthetics_run.ts b/x-pack/plugins/exploratory_view/e2e/synthetics_run.ts new file mode 100644 index 0000000000000..eadb62dc44a72 --- /dev/null +++ b/x-pack/plugins/exploratory_view/e2e/synthetics_run.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { FtrConfigProviderContext } from '@kbn/test'; +import path from 'path'; +import { SyntheticsRunner } from './synthetics_runner'; +import { argv } from './parse_args_params'; + +const { headless, grep, bail: pauseOnError } = argv; + +async function runE2ETests({ readConfigFile }: FtrConfigProviderContext) { + const kibanaConfig = await readConfigFile(require.resolve('@kbn/synthetics-plugin/e2e/config')); + + return { + ...kibanaConfig.getAll(), + testRunner: async ({ getService }: any) => { + const syntheticsRunner = new SyntheticsRunner(getService, { + headless, + match: grep, + pauseOnError, + }); + + await syntheticsRunner.setup(); + + await syntheticsRunner.loadTestData(path.join(__dirname, '../../ux/e2e/fixtures/'), [ + 'rum_8.0.0', + 'rum_test_data', + ]); + await syntheticsRunner.loadTestData( + path.join(__dirname, '../../synthetics/e2e/fixtures/es_archiver/'), + ['full_heartbeat', 'browser'] + ); + await syntheticsRunner.loadTestFiles(async () => { + require(path.join(__dirname, './journeys')); + }); + await syntheticsRunner.run(); + }, + }; +} + +// eslint-disable-next-line import/no-default-export +export default runE2ETests; diff --git a/x-pack/plugins/exploratory_view/e2e/synthetics_runner.ts b/x-pack/plugins/exploratory_view/e2e/synthetics_runner.ts new file mode 100644 index 0000000000000..66183218780f2 --- /dev/null +++ b/x-pack/plugins/exploratory_view/e2e/synthetics_runner.ts @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable no-console */ + +import Url from 'url'; +import { run as syntheticsRun } from '@elastic/synthetics'; +import { PromiseType } from 'utility-types'; +import { createApmUsers } from '@kbn/apm-plugin/server/test_helpers/create_apm_users/create_apm_users'; + +import { EsArchiver } from '@kbn/es-archiver'; +import { esArchiverUnload } from './tasks/es_archiver'; +import { TestReporter } from './test_reporter'; + +export interface ArgParams { + headless: boolean; + match?: string; + pauseOnError: boolean; +} + +export class SyntheticsRunner { + public getService: any; + public kibanaUrl: string; + private elasticsearchUrl: string; + + public testFilesLoaded: boolean = false; + + public params: ArgParams; + + private loadTestFilesCallback?: (reload?: boolean) => Promise; + + constructor(getService: any, params: ArgParams) { + this.getService = getService; + this.kibanaUrl = this.getKibanaUrl(); + this.elasticsearchUrl = this.getElasticsearchUrl(); + this.params = params; + } + + async setup() { + await this.createTestUsers(); + } + + async createTestUsers() { + await createApmUsers({ + elasticsearch: { node: this.elasticsearchUrl, username: 'elastic', password: 'changeme' }, + kibana: { hostname: this.kibanaUrl }, + }); + } + + async loadTestFiles(callback: (reload?: boolean) => Promise, reload = false) { + console.log('Loading test files'); + await callback(reload); + this.loadTestFilesCallback = callback; + this.testFilesLoaded = true; + console.log('Successfully loaded test files'); + } + + async loadTestData(e2eDir: string, dataArchives: string[]) { + try { + console.log('Loading esArchiver...'); + + const esArchiver: EsArchiver = this.getService('esArchiver'); + + const promises = dataArchives.map((archive) => { + if (archive === 'synthetics_data') { + return esArchiver.load(e2eDir + archive, { + docsOnly: true, + skipExisting: true, + }); + } + return esArchiver.load(e2eDir + archive, { skipExisting: true }); + }); + + await Promise.all([...promises]); + } catch (e) { + console.log(e); + } + } + + getKibanaUrl() { + const config = this.getService('config'); + + return Url.format({ + protocol: config.get('servers.kibana.protocol'), + hostname: config.get('servers.kibana.hostname'), + port: config.get('servers.kibana.port'), + }); + } + + getElasticsearchUrl() { + const config = this.getService('config'); + + return Url.format({ + protocol: config.get('servers.elasticsearch.protocol'), + hostname: config.get('servers.elasticsearch.hostname'), + port: config.get('servers.elasticsearch.port'), + }); + } + + async run() { + if (!this.testFilesLoaded) { + throw new Error('Test files not loaded'); + } + const { headless, match, pauseOnError } = this.params; + const noOfRuns = process.env.NO_OF_RUNS ? Number(process.env.NO_OF_RUNS) : 1; + console.log(`Running ${noOfRuns} times`); + let results: PromiseType> = {}; + for (let i = 0; i < noOfRuns; i++) { + results = await syntheticsRun({ + params: { kibanaUrl: this.kibanaUrl, getService: this.getService }, + playwrightOptions: { + headless, + chromiumSandbox: false, + timeout: 60 * 1000, + viewport: { + height: 900, + width: 1600, + }, + recordVideo: { + dir: '.journeys/videos', + }, + }, + match: match === 'undefined' ? '' : match, + pauseOnError, + screenshots: 'only-on-failure', + reporter: TestReporter, + }); + if (noOfRuns > 1) { + // need to reload again since runner resets the journeys + await this.loadTestFiles(this.loadTestFilesCallback!, true); + } + } + + await this.assertResults(results); + } + + assertResults(results: PromiseType>) { + Object.entries(results).forEach(([_journey, result]) => { + if (result.status !== 'succeeded') { + process.exitCode = 1; + process.exit(); + } + }); + } + + cleanUp() { + console.log('Removing esArchiver...'); + esArchiverUnload('full_heartbeat'); + esArchiverUnload('browser'); + } +} diff --git a/x-pack/plugins/exploratory_view/e2e/tasks/es_archiver.ts b/x-pack/plugins/exploratory_view/e2e/tasks/es_archiver.ts new file mode 100644 index 0000000000000..bbb66b19f5a5e --- /dev/null +++ b/x-pack/plugins/exploratory_view/e2e/tasks/es_archiver.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Path from 'path'; +import { execSync } from 'child_process'; + +const ES_ARCHIVE_DIR = './fixtures/es_archiver'; + +// Otherwise execSync would inject NODE_TLS_REJECT_UNAUTHORIZED=0 and node would abort if used over https +const NODE_TLS_REJECT_UNAUTHORIZED = '1'; + +export const esArchiverLoad = (folder: string) => { + const path = Path.join(ES_ARCHIVE_DIR, folder); + execSync( + `node ../../../../scripts/es_archiver load "${path}" --config ../../../test/functional/config.base.js`, + { env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED }, stdio: 'inherit' } + ); +}; + +export const esArchiverUnload = (folder: string) => { + const path = Path.join(ES_ARCHIVE_DIR, folder); + execSync( + `node ../../../../scripts/es_archiver unload "${path}" --config ../../../test/functional/config.base.js`, + { env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED }, stdio: 'inherit' } + ); +}; + +export const esArchiverResetKibana = () => { + execSync( + `node ../../../../scripts/es_archiver empty-kibana-index --config ../../../test/functional/config.base.js`, + { env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED }, stdio: 'inherit' } + ); +}; diff --git a/x-pack/plugins/exploratory_view/e2e/test_reporter.ts b/x-pack/plugins/exploratory_view/e2e/test_reporter.ts new file mode 100644 index 0000000000000..198a038ec027f --- /dev/null +++ b/x-pack/plugins/exploratory_view/e2e/test_reporter.ts @@ -0,0 +1,229 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Journey, Step } from '@elastic/synthetics/dist/dsl'; +import { Reporter, ReporterOptions } from '@elastic/synthetics'; +import { + JourneyEndResult, + JourneyStartResult, + StepEndResult, +} from '@elastic/synthetics/dist/common_types'; + +import { yellow, green, cyan, red, bold } from 'chalk'; + +// eslint-disable-next-line no-console +const log = console.log; + +import { performance } from 'perf_hooks'; +import * as fs from 'fs'; +import { gatherScreenshots } from '@elastic/synthetics/dist/reporters/json'; +import { CACHE_PATH } from '@elastic/synthetics/dist/helpers'; +import { join } from 'path'; + +function renderError(error: any) { + let output = ''; + const outer = indent(''); + const inner = indent(outer); + const container = outer + '---\n'; + output += container; + let stack = error.stack; + if (stack) { + output += inner + 'stack: |-\n'; + stack = rewriteErrorStack(stack, findPWLogsIndexes(stack)); + const lines = String(stack).split('\n'); + for (const line of lines) { + output += inner + ' ' + line + '\n'; + } + } + output += container; + return red(output); +} + +function renderDuration(durationMs: number) { + return Number(durationMs).toFixed(0); +} + +export class TestReporter implements Reporter { + metrics = { + succeeded: 0, + failed: 0, + skipped: 0, + }; + + journeys: Map> = new Map(); + + constructor(options: ReporterOptions = {}) {} + + onJourneyStart(journey: Journey, {}: JourneyStartResult) { + if (process.env.CI) { + this.write(`\n--- Journey: ${journey.name}`); + } else { + this.write(bold(`\n Journey: ${journey.name}`)); + } + } + + onStepEnd(journey: Journey, step: Step, result: StepEndResult) { + const { status, end, start, error } = result; + const message = `${symbols[status]} Step: '${step.name}' ${status} (${renderDuration( + (end - start) * 1000 + )} ms)`; + this.write(indent(message)); + if (error) { + this.write(renderError(error)); + } + this.metrics[status]++; + if (!this.journeys.has(journey.name)) { + this.journeys.set(journey.name, []); + } + this.journeys.get(journey.name)?.push({ name: step.name, ...result }); + } + + async onJourneyEnd(journey: Journey, { error, start, end, status }: JourneyEndResult) { + const { failed, succeeded, skipped } = this.metrics; + const total = failed + succeeded + skipped; + if (total === 0 && error) { + this.write(renderError(error)); + } + const message = `${symbols[status]} Took (${renderDuration(end - start)} seconds)`; + this.write(message); + + await fs.promises.mkdir('.journeys/failed_steps', { recursive: true }); + + await gatherScreenshots(join(CACHE_PATH, 'screenshots'), async (screenshot) => { + const { data, step } = screenshot; + + if (status === 'failed') { + await (async () => { + await fs.promises.writeFile(join('.journeys/failed_steps/', `${step.name}.jpg`), data, { + encoding: 'base64', + }); + })(); + } + }); + } + + onEnd() { + const failedJourneys = Array.from(this.journeys.entries()).filter(([, steps]) => + steps.some((step) => step.status === 'failed') + ); + + if (failedJourneys.length > 0) { + failedJourneys.forEach(([journeyName, steps]) => { + if (process.env.CI) { + const name = red(`Journey: ${journeyName} 🥵`); + this.write(`\n+++ ${name}`); + steps.forEach((stepResult) => { + const { status, end, start, error, name: stepName } = stepResult; + const message = `${symbols[status]} Step: '${stepName}' ${status} (${renderDuration( + (end - start) * 1000 + )} ms)`; + this.write(indent(message)); + if (error) { + this.write(renderError(error)); + } + }); + } + }); + } + + const successfulJourneys = Array.from(this.journeys.entries()).filter(([, steps]) => + steps.every((step) => step.status === 'succeeded') + ); + + successfulJourneys.forEach(([journeyName, steps]) => { + try { + fs.unlinkSync('.journeys/videos/' + journeyName + '.webm'); + } catch (e) { + // eslint-disable-next-line no-console + console.log( + 'Failed to delete video file for path ' + '.journeys/videos/' + journeyName + '.webm' + ); + } + }); + + const { failed, succeeded, skipped } = this.metrics; + const total = failed + succeeded + skipped; + + let message = '\n'; + if (total === 0) { + message = 'No tests found!'; + message += ` (${renderDuration(now())} ms) \n`; + this.write(message); + return; + } + + message += succeeded > 0 ? green(` ${succeeded} passed`) : ''; + message += failed > 0 ? red(` ${failed} failed`) : ''; + message += skipped > 0 ? cyan(` ${skipped} skipped`) : ''; + message += ` (${renderDuration(now() / 1000)} seconds) \n`; + this.write(message); + } + + write(message: any) { + if (typeof message === 'object') { + message = JSON.stringify(message); + } + log(message + '\n'); + } +} + +const SEPARATOR = '\n'; + +function indent(lines: string, tab = ' ') { + return lines.replace(/^/gm, tab); +} + +const NO_UTF8_SUPPORT = process.platform === 'win32'; +const symbols = { + warning: yellow(NO_UTF8_SUPPORT ? '!' : '⚠'), + skipped: cyan('-'), + progress: cyan('>'), + succeeded: green(NO_UTF8_SUPPORT ? 'ok' : '✓'), + failed: red(NO_UTF8_SUPPORT ? 'x' : '✖'), +}; + +function now() { + return performance.now(); +} + +function findPWLogsIndexes(msgOrStack: string): [number, number] { + let startIndex = 0; + let endIndex = 0; + if (!msgOrStack) { + return [startIndex, endIndex]; + } + const lines = String(msgOrStack).split(SEPARATOR); + const logStart = /[=]{3,} logs [=]{3,}/; + const logEnd = /[=]{10,}/; + lines.forEach((line, index) => { + if (logStart.test(line)) { + startIndex = index; + } else if (logEnd.test(line)) { + endIndex = index; + } + }); + return [startIndex, endIndex]; +} + +function rewriteErrorStack(stack: string, indexes: [number, number]) { + const [start, end] = indexes; + /** + * Do not rewrite if its not a playwright error + */ + if (start === 0 && end === 0) { + return stack; + } + const linesToKeep = start + 3; + if (start > 0 && linesToKeep < end) { + const lines = stack.split(SEPARATOR); + return lines + .slice(0, linesToKeep) + .concat(...lines.slice(end)) + .join(SEPARATOR); + } + return stack; +} diff --git a/x-pack/plugins/exploratory_view/e2e/tsconfig.json b/x-pack/plugins/exploratory_view/e2e/tsconfig.json new file mode 100644 index 0000000000000..df3428e1aa0bf --- /dev/null +++ b/x-pack/plugins/exploratory_view/e2e/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.base.json", + "exclude": ["tmp", "target/**/*"], + "include": ["./**/*"], + "compilerOptions": { + "outDir": "target/types", + "types": [ "node"], + }, +} diff --git a/x-pack/plugins/exploratory_view/e2e/utils.ts b/x-pack/plugins/exploratory_view/e2e/utils.ts new file mode 100644 index 0000000000000..f340ef8b78b3a --- /dev/null +++ b/x-pack/plugins/exploratory_view/e2e/utils.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { expect, Page } from '@elastic/synthetics'; + +export async function waitForLoadingToFinish({ page }: { page: Page }) { + while (true) { + if (!(await page.isVisible(byTestId('kbnLoadingMessage'), { timeout: 5000 }))) break; + await page.waitForTimeout(1000); + } +} + +export async function loginToKibana({ + page, + user, +}: { + page: Page; + user?: { username: string; password: string }; +}) { + await page.fill('[data-test-subj=loginUsername]', user?.username ?? 'elastic', { + timeout: 60 * 1000, + }); + + await page.fill('[data-test-subj=loginPassword]', user?.password ?? 'changeme'); + + await page.click('[data-test-subj=loginSubmit]'); + + await waitForLoadingToFinish({ page }); +} + +export const byTestId = (testId: string) => { + return `[data-test-subj=${testId}]`; +}; + +export const assertText = async ({ page, text }: { page: Page; text: string }) => { + const element = await page.waitForSelector(`text=${text}`); + expect(await element.isVisible()).toBeTruthy(); +}; + +export const assertNotText = async ({ page, text }: { page: Page; text: string }) => { + expect(await page.$(`text=${text}`)).toBeFalsy(); +}; + +export const getQuerystring = (params: object) => { + return Object.entries(params) + .map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(value)) + .join('&'); +}; + +export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export const TIMEOUT_60_SEC = { + timeout: 60 * 1000, +}; diff --git a/x-pack/plugins/exploratory_view/jest.config.js b/x-pack/plugins/exploratory_view/jest.config.js new file mode 100644 index 0000000000000..55f93dc2afaf9 --- /dev/null +++ b/x-pack/plugins/exploratory_view/jest.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/exploratory_view'], + setupFiles: ['/x-pack/plugins/exploratory_view/.storybook/jest_setup.js'], + coverageDirectory: '/target/kibana-coverage/jest/x-pack/plugins/exploratory_view', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/plugins/exploratory_view/{common,public,server}/**/*.{js,ts,tsx}', + ], +}; diff --git a/x-pack/plugins/exploratory_view/kibana.jsonc b/x-pack/plugins/exploratory_view/kibana.jsonc new file mode 100644 index 0000000000000..0c882f4f09a20 --- /dev/null +++ b/x-pack/plugins/exploratory_view/kibana.jsonc @@ -0,0 +1,49 @@ +{ + "type": "plugin", + "id": "@kbn/exploratory-view-plugin", + "owner": "@elastic/uptime", + "plugin": { + "id": "exploratoryView", + "server": true, + "browser": true, + "configPath": ["xpack", "exploratory_view"], + "requiredPlugins": [ + "alerting", + "cases", + "charts", + "data", + "dataViews", + "features", + "files", + "guidedOnboarding", + "inspector", + "inspector", + "observability", + "security", + "share", + "triggersActionsUi", + "unifiedSearch" + ], + "optionalPlugins": [ + "discover", + "embeddable", + "home", + "lens", + "licensing", + "spaces", + "usageCollection" + ], + "requiredBundles": [ + "data", + "dataViews", + "embeddable", + "kibanaReact", + "kibanaUtils", + "lens", + "observability", + "unifiedSearch", + "visualizations" + ], + "extraPublicDirs": ["common"] + } +} diff --git a/x-pack/plugins/exploratory_view/public/application/application.test.tsx b/x-pack/plugins/exploratory_view/public/application/application.test.tsx new file mode 100644 index 0000000000000..2f51f32050a22 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/application/application.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createMemoryHistory } from 'history'; +import { noop } from 'lodash'; +import React from 'react'; +import { Observable } from 'rxjs'; +import { AppMountParameters, CoreStart } from '@kbn/core/public'; +import { themeServiceMock } from '@kbn/core/public/mocks'; +import { ExploratoryViewPublicPluginsStart } from '../plugin'; +import { renderApp } from '.'; + +describe('renderApp', () => { + const originalConsole = global.console; + + beforeAll(() => { + // mocks console to avoid polluting the test output + global.console = { error: jest.fn() } as unknown as typeof console; + }); + + afterAll(() => { + global.console = originalConsole; + }); + + it('renders', async () => { + const plugins = { + usageCollection: { reportUiCounter: noop }, + data: { + query: { + timefilter: { + timefilter: { + setTime: jest.fn(), + getTime: jest.fn().mockReturnValue({}), + getTimeDefaults: jest.fn().mockReturnValue({}), + getRefreshInterval: jest.fn().mockReturnValue({}), + getRefreshIntervalDefaults: jest.fn().mockReturnValue({}), + }, + }, + }, + }, + } as unknown as ExploratoryViewPublicPluginsStart; + + const core = { + application: { currentAppId$: new Observable(), navigateToUrl: noop }, + chrome: { + docTitle: { change: noop }, + setBreadcrumbs: noop, + setHelpExtension: noop, + }, + i18n: { Context: ({ children }: { children: React.ReactNode }) => children }, + uiSettings: { get: () => false }, + http: { basePath: { prepend: (path: string) => path } }, + theme: themeServiceMock.createStartContract(), + } as unknown as CoreStart; + + const params = { + element: window.document.createElement('div'), + history: createMemoryHistory(), + setHeaderActionMenu: noop, + theme$: themeServiceMock.createTheme$(), + } as unknown as AppMountParameters; + + expect(() => { + const unmount = renderApp({ + core, + plugins, + appMountParameters: params, + usageCollection: { + components: { + ApplicationUsageTrackingProvider: (props) => null, + }, + reportUiCounter: jest.fn(), + }, + }); + unmount(); + }).not.toThrowError(); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/application/index.tsx b/x-pack/plugins/exploratory_view/public/application/index.tsx new file mode 100644 index 0000000000000..83a5851c70669 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/application/index.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiErrorBoundary } from '@elastic/eui'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Router, Switch } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { Route } from '@kbn/shared-ux-router'; +import { AppMountParameters, APP_WRAPPER_CLASS, CoreStart } from '@kbn/core/public'; +import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; +import { + KibanaContextProvider, + KibanaThemeProvider, + RedirectAppLinks, +} from '@kbn/kibana-react-plugin/public'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; +import { PluginContext } from '../context/plugin_context'; +import { routes } from '../routes'; +import { HasDataContextProvider } from '../context/has_data_context'; +import { ExploratoryViewPublicPluginsStart } from '../plugin'; + +function App() { + return ( + <> + + {Object.keys(routes).map((key) => { + const path = key as keyof typeof routes; + const { handler, exact } = routes[path]; + const Wrapper = () => { + return handler(); + }; + return ; + })} + + + ); +} + +export const renderApp = ({ + core, + appMountParameters, + plugins, + usageCollection, + isDev, +}: { + core: CoreStart; + appMountParameters: AppMountParameters; + plugins: ExploratoryViewPublicPluginsStart; + usageCollection: UsageCollectionSetup; + isDev?: boolean; +}) => { + const { element, history, theme$ } = appMountParameters; + const i18nCore = core.i18n; + const isDarkMode = core.uiSettings.get('theme:darkMode'); + + core.chrome.setHelpExtension({ + appName: i18n.translate('xpack.exploratoryView.feedbackMenu.appName', { + defaultMessage: 'Observability', + }), + links: [{ linkType: 'discuss', href: 'https://ela.st/observability-discuss' }], + }); + + // ensure all divs are .kbnAppWrappers + element.classList.add(APP_WRAPPER_CLASS); + + const ApplicationUsageTrackingProvider = + usageCollection?.components.ApplicationUsageTrackingProvider ?? React.Fragment; + + ReactDOM.render( + + + + + + + + + + + + + + + + + + + + + , + element + ); + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/x-pack/plugins/exploratory_view/public/application/types.ts b/x-pack/plugins/exploratory_view/public/application/types.ts new file mode 100644 index 0000000000000..1a7d9a4d92891 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/application/types.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + ApplicationStart, + ChromeStart, + DocLinksStart, + HttpStart, + IUiSettingsClient, + NotificationsStart, + OverlayStart, + SavedObjectsStart, + ThemeServiceStart, +} from '@kbn/core/public'; +import { EmbeddableStateTransfer } from '@kbn/embeddable-plugin/public'; +import { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public'; +import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { LensPublicStart } from '@kbn/lens-plugin/public'; +import { SharePluginStart } from '@kbn/share-plugin/public'; +import { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; +import { CasesUiStart } from '@kbn/cases-plugin/public'; +import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; + +export interface ObservabilityAppServices { + application: ApplicationStart; + cases: CasesUiStart; + charts: ChartsPluginStart; + chrome: ChromeStart; + data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; + docLinks: DocLinksStart; + http: HttpStart; + lens: LensPublicStart; + navigation: NavigationPublicPluginStart; + notifications: NotificationsStart; + overlays: OverlayStart; + savedObjectsClient: SavedObjectsStart['client']; + share: SharePluginStart; + stateTransfer: EmbeddableStateTransfer; + storage: IStorageWrapper; + theme: ThemeServiceStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; + uiSettings: IUiSettingsClient; + isDev?: boolean; + kibanaVersion: string; +} diff --git a/x-pack/plugins/exploratory_view/public/assets/illustration_dark.svg b/x-pack/plugins/exploratory_view/public/assets/illustration_dark.svg new file mode 100644 index 0000000000000..44815a7455144 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/assets/illustration_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/exploratory_view/public/assets/illustration_light.svg b/x-pack/plugins/exploratory_view/public/assets/illustration_light.svg new file mode 100644 index 0000000000000..1690c68fd595a --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/assets/illustration_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/exploratory_view/public/assets/kibana_dashboard_dark.svg b/x-pack/plugins/exploratory_view/public/assets/kibana_dashboard_dark.svg new file mode 100644 index 0000000000000..834dd98d60e4c --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/assets/kibana_dashboard_dark.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/x-pack/plugins/exploratory_view/public/assets/kibana_dashboard_light.svg b/x-pack/plugins/exploratory_view/public/assets/kibana_dashboard_light.svg new file mode 100644 index 0000000000000..958d25362c439 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/assets/kibana_dashboard_light.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/x-pack/plugins/exploratory_view/public/assets/onboarding_tour_step_alerts.gif b/x-pack/plugins/exploratory_view/public/assets/onboarding_tour_step_alerts.gif new file mode 100644 index 0000000000000..7b81b26ccebbd Binary files /dev/null and b/x-pack/plugins/exploratory_view/public/assets/onboarding_tour_step_alerts.gif differ diff --git a/x-pack/plugins/exploratory_view/public/assets/onboarding_tour_step_logs.gif b/x-pack/plugins/exploratory_view/public/assets/onboarding_tour_step_logs.gif new file mode 100644 index 0000000000000..b3b1668e43067 Binary files /dev/null and b/x-pack/plugins/exploratory_view/public/assets/onboarding_tour_step_logs.gif differ diff --git a/x-pack/plugins/exploratory_view/public/assets/onboarding_tour_step_metrics.gif b/x-pack/plugins/exploratory_view/public/assets/onboarding_tour_step_metrics.gif new file mode 100644 index 0000000000000..9883b732b98c5 Binary files /dev/null and b/x-pack/plugins/exploratory_view/public/assets/onboarding_tour_step_metrics.gif differ diff --git a/x-pack/plugins/exploratory_view/public/assets/onboarding_tour_step_services.gif b/x-pack/plugins/exploratory_view/public/assets/onboarding_tour_step_services.gif new file mode 100644 index 0000000000000..7c282e5b4eb24 Binary files /dev/null and b/x-pack/plugins/exploratory_view/public/assets/onboarding_tour_step_services.gif differ diff --git a/x-pack/plugins/exploratory_view/public/components/shared/action_menu/index.tsx b/x-pack/plugins/exploratory_view/public/components/shared/action_menu/index.tsx new file mode 100644 index 0000000000000..08ff33cfcfe95 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/action_menu/index.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiPopover, + EuiText, + EuiListGroup, + EuiSpacer, + EuiHorizontalRule, + EuiListGroupItem, + EuiPopoverProps, + EuiListGroupItemProps, +} from '@elastic/eui'; +import React, { HTMLAttributes, ReactNode } from 'react'; +import styled from 'styled-components'; +import { EuiListGroupProps } from '@elastic/eui'; + +type Props = EuiPopoverProps & HTMLAttributes; + +export function SectionTitle({ children }: { children?: ReactNode }) { + return ( + <> + +
{children}
+
+ + + ); +} + +export function SectionSubtitle({ children }: { children?: ReactNode }) { + return ( + <> + + {children} + + + + ); +} + +export function SectionLinks({ children, ...props }: { children?: ReactNode } & EuiListGroupProps) { + return ( + + {children} + + ); +} + +export function SectionSpacer() { + return ; +} + +export const Section = styled.div` + margin-bottom: 16px; + &:last-of-type { + margin-bottom: 0; + } +`; + +export type SectionLinkProps = EuiListGroupItemProps; +export function SectionLink(props: SectionLinkProps) { + return ; +} + +export function ActionMenuDivider() { + return ; +} + +export function ActionMenu(props: Props) { + return ; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/add_data_buttons/mobile_add_data.tsx b/x-pack/plugins/exploratory_view/public/components/shared/add_data_buttons/mobile_add_data.tsx new file mode 100644 index 0000000000000..0f4ad6352555d --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/add_data_buttons/mobile_add_data.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiHeaderLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useKibana } from '../../../utils/kibana_react'; + +export function MobileAddData() { + const kibana = useKibana(); + + return ( + + {ADD_DATA_LABEL} + + ); +} + +const ADD_DATA_LABEL = i18n.translate('xpack.exploratoryView.mobile.addDataButtonLabel', { + defaultMessage: 'Add Mobile data', +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/add_data_buttons/synthetics_add_data.tsx b/x-pack/plugins/exploratory_view/public/components/shared/add_data_buttons/synthetics_add_data.tsx new file mode 100644 index 0000000000000..79c43890349c0 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/add_data_buttons/synthetics_add_data.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiHeaderLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useKibana } from '../../../utils/kibana_react'; + +export function SyntheticsAddData() { + const kibana = useKibana(); + + return ( + + {ADD_DATA_LABEL} + + ); +} + +const ADD_DATA_LABEL = i18n.translate('xpack.exploratoryView..synthetics.addDataButtonLabel', { + defaultMessage: 'Add synthetics data', +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/add_data_buttons/ux_add_data.tsx b/x-pack/plugins/exploratory_view/public/components/shared/add_data_buttons/ux_add_data.tsx new file mode 100644 index 0000000000000..e88a977b1c25e --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/add_data_buttons/ux_add_data.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiHeaderLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useKibana } from '../../../utils/kibana_react'; + +export function UXAddData() { + const kibana = useKibana(); + + return ( + + {ADD_DATA_LABEL} + + ); +} + +const ADD_DATA_LABEL = i18n.translate('xpack.exploratoryView.ux.addDataButtonLabel', { + defaultMessage: 'Add UX data', +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/__stories__/core_vitals.stories.tsx b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/__stories__/core_vitals.stories.tsx new file mode 100644 index 0000000000000..91d2f03101bf2 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/__stories__/core_vitals.stories.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, { ComponentType } from 'react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { Observable } from 'rxjs'; +import { CoreStart } from '@kbn/core/public'; +import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; +import { CoreVitalItem } from '../core_vital_item'; +import { LCP_HELP_LABEL, LCP_LABEL } from '../translations'; + +const KibanaReactContext = createKibanaReactContext({ + uiSettings: { get: () => {}, get$: () => new Observable() }, +} as unknown as Partial); + +export default { + title: 'app/RumDashboard/CoreVitalItem', + component: CoreVitalItem, + decorators: [ + (Story: ComponentType) => ( + + + + + + ), + ], +}; + +export function NoDataAvailable() { + return ( + + ); +} + +export function OneHundredPercentGood() { + return ( + + ); +} + +export function FiftyPercentGood() { + return ( + + ); +} + +export function OneHundredPercentBad() { + return ( + + ); +} + +export function OneHundredPercentAverage() { + return ( + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/color_palette_flex_item.tsx b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/color_palette_flex_item.tsx new file mode 100644 index 0000000000000..45ff1cbab8cb9 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/color_palette_flex_item.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import styled from 'styled-components'; + +const ColoredSpan = styled.div` + height: 16px; + width: 100%; + cursor: pointer; +`; + +const getSpanStyle = (position: number, inFocus: boolean, hexCode: string, percentage: number) => { + let first = position === 0 || percentage === 100; + let last = position === 2 || percentage === 100; + if (percentage === 100) { + first = true; + last = true; + } + + const spanStyle: any = { + backgroundColor: hexCode, + opacity: !inFocus ? 1 : 0.3, + }; + let borderRadius = ''; + + if (first) { + borderRadius = '4px 0 0 4px'; + } + if (last) { + borderRadius = '0 4px 4px 0'; + } + if (first && last) { + borderRadius = '4px'; + } + spanStyle.borderRadius = borderRadius; + + return spanStyle; +}; + +export function ColorPaletteFlexItem({ + hexCode, + inFocus, + percentage, + tooltip, + position, +}: { + hexCode: string; + position: number; + inFocus: boolean; + percentage: number; + tooltip: string; +}) { + const spanStyle = getSpanStyle(position, inFocus, hexCode, percentage); + + return ( + + + + + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/core_vital_item.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/core_vital_item.test.tsx new file mode 100644 index 0000000000000..da618350ddf6a --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/core_vital_item.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '../../../utils/test_helper'; +import { CoreVitalItem } from './core_vital_item'; +import { + NO_DATA, + LEGEND_GOOD_LABEL, + LEGEND_NEEDS_IMPROVEMENT_LABEL, + LEGEND_POOR_LABEL, +} from './translations'; + +describe('CoreVitalItem', () => { + const value = '0.005'; + const title = 'Cumulative Layout Shift'; + const thresholds = { bad: '0.25', good: '0.1' }; + const loading = false; + const helpLabel = 'sample help label'; + + it('renders if value is truthy', () => { + const { getByText, getByTestId } = render( + + ); + + expect(getByText(title)).toBeInTheDocument(); + expect(getByText(value)).toBeInTheDocument(); + + expect(getByTestId(`${LEGEND_GOOD_LABEL}-85`)).toBeInTheDocument(); + expect(getByTestId(`${LEGEND_NEEDS_IMPROVEMENT_LABEL}-10`)).toBeInTheDocument(); + expect(getByTestId(`${LEGEND_POOR_LABEL}-5`)).toBeInTheDocument(); + }); + + it('renders loading state when loading is truthy', () => { + const { queryByText, getByText } = render( + + ); + + expect(queryByText(value)).not.toBeInTheDocument(); + expect(getByText('--')).toBeInTheDocument(); + }); + + it('renders no data UI if value is falsey and loading is falsey', () => { + const { getByText } = render( + + ); + + expect(getByText(NO_DATA)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/core_vital_item.tsx b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/core_vital_item.tsx new file mode 100644 index 0000000000000..8af4e423c7249 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/core_vital_item.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiCard, + EuiFlexGroup, + EuiIconTip, + euiPaletteForStatus, + EuiSpacer, + EuiStat, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { PaletteLegends } from './palette_legends'; +import { ColorPaletteFlexItem } from './color_palette_flex_item'; +import { + CV_AVERAGE_LABEL, + CV_GOOD_LABEL, + LESS_LABEL, + MORE_LABEL, + NO_DATA, + CV_POOR_LABEL, + IS_LABEL, + TAKES_LABEL, +} from './translations'; + +export interface Thresholds { + good: string; + bad: string; +} + +interface Props { + title: string; + value?: string | null; + ranks?: number[]; + loading: boolean; + thresholds: Thresholds; + isCls?: boolean; + helpLabel: string; +} + +export function getCoreVitalTooltipMessage( + thresholds: Thresholds, + position: number, + title: string, + percentage: number, + isCls?: boolean +) { + const good = position === 0; + const bad = position === 2; + const average = !good && !bad; + + return i18n.translate('xpack.exploratoryView.ux.dashboard.webVitals.palette.tooltip', { + defaultMessage: + '{percentage} % of users have {exp} experience because the {title} {isOrTakes} {moreOrLess} than {value}{averageMessage}.', + values: { + percentage, + isOrTakes: isCls ? IS_LABEL : TAKES_LABEL, + title: title?.toLowerCase(), + exp: good ? CV_GOOD_LABEL : bad ? CV_POOR_LABEL : CV_AVERAGE_LABEL, + moreOrLess: bad || average ? MORE_LABEL : LESS_LABEL, + value: good || average ? thresholds.good : thresholds.bad, + averageMessage: average + ? i18n.translate('xpack.exploratoryView.ux.coreVitals.averageMessage', { + defaultMessage: ' and less than {bad}', + values: { bad: thresholds.bad }, + }) + : '', + }, + }); +} + +export function CoreVitalItem({ + loading, + title, + value, + thresholds, + ranks = [100, 0, 0], + isCls, + helpLabel, +}: Props) { + const palette = euiPaletteForStatus(3); + + const [inFocusInd, setInFocusInd] = useState(null); + + const biggestValIndex = ranks.indexOf(Math.max(...ranks)); + + if (!value && !loading) { + return ; + } + + return ( + <> + + {title} + + + } + titleColor={palette[biggestValIndex]} + isLoading={loading} + /> + + + {palette.map((hexCode, ind) => ( + + ))} + + + { + setInFocusInd(ind); + }} + isCls={isCls} + /> + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/index.tsx b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/index.tsx new file mode 100644 index 0000000000000..939ed1d9b27e4 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/index.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { + CLS_HELP_LABEL, + CLS_LABEL, + FID_HELP_LABEL, + FID_LABEL, + LCP_HELP_LABEL, + LCP_LABEL, +} from './translations'; +import { CoreVitalItem } from './core_vital_item'; +import { WebCoreVitalsTitle } from './web_core_vitals_title'; +import { ServiceName } from './service_name'; +import { CoreVitalProps } from '../types'; + +export interface UXMetrics { + cls: number | null; + fid?: number | null; + lcp?: number | null; + tbt: number; + fcp?: number | null; + coreVitalPages: number; + lcpRanks: number[]; + fidRanks: number[]; + clsRanks: number[]; +} + +function formatToSec(value?: number | string, fromUnit = 'MicroSec'): string { + const valueInMs = Number(value ?? 0) / (fromUnit === 'MicroSec' ? 1000 : 1); + + if (valueInMs < 1000) { + return valueInMs.toFixed(0) + ' ms'; + } + return (valueInMs / 1000).toFixed(2) + ' s'; +} + +function formatToMilliseconds(value?: number | null) { + if (typeof value === 'undefined' || value === null) { + return null; + } + return formatToSec(value, 'ms'); +} + +const CoreVitalsThresholds = { + LCP: { good: '2.5s', bad: '4.0s' }, + FID: { good: '100ms', bad: '300ms' }, + CLS: { good: '0.1', bad: '0.25' }, +}; + +// eslint-disable-next-line import/no-default-export +export default function CoreVitals({ + data, + loading, + displayServiceName, + serviceName, + totalPageViews, + displayTrafficMetric = false, +}: CoreVitalProps) { + const { lcp, lcpRanks, fid, fidRanks, cls, clsRanks, coreVitalPages } = data || {}; + + return ( + <> + + + {displayServiceName && } + + + + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/palette_legends.tsx b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/palette_legends.tsx new file mode 100644 index 0000000000000..ade30308e06ff --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/palette_legends.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + euiPaletteForStatus, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { euiLightVars, euiDarkVars } from '@kbn/ui-theme'; +import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; +import { getCoreVitalTooltipMessage, Thresholds } from './core_vital_item'; +import { + LEGEND_NEEDS_IMPROVEMENT_LABEL, + LEGEND_GOOD_LABEL, + LEGEND_POOR_LABEL, +} from './translations'; + +const PaletteLegend = styled(EuiHealth)` + &:hover { + cursor: pointer; + text-decoration: underline; + } +`; + +const StyledSpan = styled.span<{ + darkMode: boolean; +}>` + &:hover { + background-color: ${(props) => + props.darkMode ? euiDarkVars.euiColorLightestShade : euiLightVars.euiColorLightestShade}; + } +`; + +interface Props { + onItemHover: (ind: number | null) => void; + ranks: number[]; + thresholds: Thresholds; + title: string; + isCls?: boolean; +} + +export function PaletteLegends({ ranks, title, onItemHover, thresholds, isCls }: Props) { + const [darkMode] = useUiSetting$('theme:darkMode'); + + const palette = euiPaletteForStatus(3); + const labels = [LEGEND_GOOD_LABEL, LEGEND_NEEDS_IMPROVEMENT_LABEL, LEGEND_POOR_LABEL]; + + return ( + + {palette.map((color, ind) => ( + { + onItemHover(ind); + }} + onMouseLeave={() => { + onItemHover(null); + }} + > + + + + + + + + + + + ))} + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/service_name.tsx b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/service_name.tsx new file mode 100644 index 0000000000000..8494ace6e4378 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/service_name.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiIconTip, EuiText, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +interface Props { + name: string; +} + +const SERVICE_LABEL = i18n.translate('xpack.exploratoryView.ux.coreWebVitals.service', { + defaultMessage: 'Service', +}); + +const SERVICE_LABEL_HELP = i18n.translate('xpack.exploratoryView.ux.service.help', { + defaultMessage: 'The RUM service with the most traffic is selected', +}); + +export function ServiceName({ name }: Props) { + return ( + <> + + {SERVICE_LABEL} + + + +

{name}

+
+ + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/translations.ts b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/translations.ts new file mode 100644 index 0000000000000..a387ae3eea363 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/translations.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const NO_DATA = i18n.translate('xpack.exploratoryView.ux.coreVitals.noData', { + defaultMessage: 'No data is available.', +}); + +export const LCP_LABEL = i18n.translate('xpack.exploratoryView.ux.coreVitals.lcp', { + defaultMessage: 'Largest contentful paint', +}); + +export const FID_LABEL = i18n.translate('xpack.exploratoryView.ux.coreVitals.fip', { + defaultMessage: 'First input delay', +}); + +export const CLS_LABEL = i18n.translate('xpack.exploratoryView.ux.coreVitals.cls', { + defaultMessage: 'Cumulative layout shift', +}); + +export const CV_POOR_LABEL = i18n.translate('xpack.exploratoryView.ux.coreVitals.poor', { + defaultMessage: 'a poor', +}); + +export const CV_GOOD_LABEL = i18n.translate('xpack.exploratoryView.ux.coreVitals.good', { + defaultMessage: 'a good', +}); + +export const CV_AVERAGE_LABEL = i18n.translate('xpack.exploratoryView.ux.coreVitals.average', { + defaultMessage: 'an average', +}); + +export const LEGEND_POOR_LABEL = i18n.translate( + 'xpack.exploratoryView.ux.coreVitals.legends.poor', + { + defaultMessage: 'Poor', + } +); + +export const LEGEND_GOOD_LABEL = i18n.translate( + 'xpack.exploratoryView.ux.coreVitals.legends.good', + { + defaultMessage: 'Good', + } +); + +export const LEGEND_NEEDS_IMPROVEMENT_LABEL = i18n.translate( + 'xpack.exploratoryView.ux.coreVitals.legends.needsImprovement', + { + defaultMessage: 'Needs improvement', + } +); + +export const MORE_LABEL = i18n.translate('xpack.exploratoryView.ux.coreVitals.more', { + defaultMessage: 'more', +}); + +export const LESS_LABEL = i18n.translate('xpack.exploratoryView.ux.coreVitals.less', { + defaultMessage: 'less', +}); + +export const IS_LABEL = i18n.translate('xpack.exploratoryView.ux.coreVitals.is', { + defaultMessage: 'is', +}); + +export const TAKES_LABEL = i18n.translate('xpack.exploratoryView.ux.coreVitals.takes', { + defaultMessage: 'takes', +}); + +export const LCP_HELP_LABEL = i18n.translate('xpack.exploratoryView.ux.coreVitals.lcp.help', { + defaultMessage: + 'Largest contentful paint measures loading performance. To provide a good user experience, LCP should occur within 2.5 seconds of when the page first starts loading.', +}); + +export const FID_HELP_LABEL = i18n.translate('xpack.exploratoryView.ux.coreVitals.fid.help', { + defaultMessage: + 'First input delay measures interactivity. To provide a good user experience, pages should have a FID of less than 100 milliseconds.', +}); + +export const CLS_HELP_LABEL = i18n.translate('xpack.exploratoryView.ux.coreVitals.cls.help', { + defaultMessage: + 'Cumulative Layout Shift (CLS): measures visual stability. To provide a good user experience, pages should maintain a CLS of less than 0.1.', +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/web_core_vitals_title.tsx b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/web_core_vitals_title.tsx new file mode 100644 index 0000000000000..b05deb899fc8c --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/core_web_vitals/web_core_vitals_title.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiLoadingSpinner, + EuiPopover, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; + +const CORE_WEB_VITALS = i18n.translate('xpack.exploratoryView.ux.coreWebVitals', { + defaultMessage: 'Core web vitals', +}); + +const BROWSER_CORE_WEB_VITALS = i18n.translate( + 'xpack.exploratoryView.ux.coreWebVitals.browser.support', + { + defaultMessage: 'browser support for core web vitals', + } +); + +export function WebCoreVitalsTitle({ + loading, + coreVitalPages, + totalPageViews = 0, + displayTrafficMetric, +}: { + loading: boolean; + coreVitalPages?: number; + totalPageViews?: number; + displayTrafficMetric: boolean; +}) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isBrowserPopoverOpen, setIsBrowserPopoverOpen] = useState(false); + + const closePopover = () => setIsPopoverOpen(false); + const closeBrowserPopover = () => setIsBrowserPopoverOpen(false); + + const helpAriaLabel = i18n.translate( + 'xpack.exploratoryView.ux.dashboard.webCoreVitals.helpAriaLabel', + { defaultMessage: 'help' } + ); + + return ( + + + +

+ {CORE_WEB_VITALS} + setIsPopoverOpen(true)} + color={'text'} + iconType={'questionInCircle'} + /> + } + closePopover={closePopover} + > +
+ + {' '} + + {CORE_WEB_VITALS} + + +
+
+

+
+
+ {displayTrafficMetric && totalPageViews > 0 && ( + + {loading ? ( + + ) : ( + + {(((coreVitalPages || 0) / totalPageViews) * 100).toFixed(0)}% + ), + }} + /> + + setIsBrowserPopoverOpen(true)} + color={'text'} + iconType={'questionInCircle'} + /> + } + closePopover={closeBrowserPopover} + > +
+ + {' '} + + {BROWSER_CORE_WEB_VITALS} + + +
+
+
+ )} +
+ )} +
+ ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/date_picker/date_picker.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/date_picker/date_picker.test.tsx new file mode 100644 index 0000000000000..45eb5e6951f2f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/date_picker/date_picker.test.tsx @@ -0,0 +1,170 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiSuperDatePicker } from '@elastic/eui'; +import { waitFor } from '@testing-library/react'; +import { mount } from 'enzyme'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import React from 'react'; +import { Router, useLocation } from 'react-router-dom'; +import qs from 'query-string'; +import { DatePicker } from '.'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { of } from 'rxjs'; +import { DatePickerContextProvider } from '../../../context/date_picker_context'; + +let history: MemoryHistory; + +const mockRefreshTimeRange = jest.fn(); +let mockHistoryPush: jest.SpyInstance; +let mockHistoryReplace: jest.SpyInstance; + +function DatePickerWrapper() { + const location = useLocation(); + + const { rangeFrom, rangeTo, refreshInterval, refreshPaused } = qs.parse(location.search, { + parseNumbers: true, + parseBooleans: true, + }) as { + rangeFrom?: string; + rangeTo?: string; + refreshInterval?: number; + refreshPaused?: boolean; + }; + + return ( + + ); +} + +function mountDatePicker(initialParams: { + rangeFrom?: string; + rangeTo?: string; + refreshInterval?: number; + refreshPaused?: boolean; +}) { + const setTimeSpy = jest.fn(); + const getTimeSpy = jest.fn().mockReturnValue({}); + + history = createMemoryHistory({ + initialEntries: [`/?${qs.stringify(initialParams)}`], + }); + + mockHistoryPush = jest.spyOn(history, 'push'); + mockHistoryReplace = jest.spyOn(history, 'replace'); + + const wrapper = mount( + + [], + get$: (key: string) => of(true), + }, + }} + > + + + + + + ); + + return { wrapper, setTimeSpy, getTimeSpy }; +} + +describe('DatePicker', () => { + beforeAll(() => { + jest.spyOn(console, 'error').mockImplementation(() => null); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('updates the URL when the date range changes', () => { + const { wrapper } = mountDatePicker({ + rangeFrom: 'now-15m', + rangeTo: 'now', + }); + + // It updates the URL when it doesn't contain the range. + expect(mockHistoryReplace).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledTimes(0); + + wrapper.find(EuiSuperDatePicker).props().onTimeChange({ + start: 'now-90m', + end: 'now-60m', + isInvalid: false, + isQuickSelection: true, + }); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenLastCalledWith( + expect.objectContaining({ + search: 'rangeFrom=now-90m&rangeTo=now-60m', + }) + ); + }); + + it('enables auto-refresh when refreshPaused is false', async () => { + jest.useFakeTimers({ legacyFakeTimers: true }); + const { wrapper } = mountDatePicker({ + rangeFrom: 'now-15m', + rangeTo: 'now', + refreshPaused: false, + refreshInterval: 1000, + }); + expect(mockRefreshTimeRange).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1000); + await waitFor(() => {}); + expect(mockRefreshTimeRange).toHaveBeenCalled(); + wrapper.unmount(); + }); + + it('disables auto-refresh when refreshPaused is true', async () => { + jest.useFakeTimers({ legacyFakeTimers: true }); + mountDatePicker({ + rangeFrom: 'now-15m', + rangeTo: 'now', + refreshPaused: true, + refreshInterval: 1000, + }); + expect(mockRefreshTimeRange).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1000); + await waitFor(() => {}); + expect(mockRefreshTimeRange).not.toHaveBeenCalled(); + }); + + describe('if both `rangeTo` and `rangeFrom` is set', () => { + it('does not update the url', () => { + expect(mockHistoryReplace).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/date_picker/index.tsx b/x-pack/plugins/exploratory_view/public/components/shared/date_picker/index.tsx new file mode 100644 index 0000000000000..8226e4081b633 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/date_picker/index.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiSuperDatePicker } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { UI_SETTINGS, useKibanaUISettings } from '../../../hooks/use_kibana_ui_settings'; +import { TimePickerQuickRange } from './typings'; +import { useDatePickerContext } from '../../../hooks/use_date_picker_context'; + +export interface DatePickerProps { + rangeFrom?: string; + rangeTo?: string; + refreshPaused?: boolean; + refreshInterval?: number; + width?: 'auto' | 'restricted' | 'full'; + onTimeRangeRefresh?: (range: { start: string; end: string }) => void; +} + +export function DatePicker({ + rangeFrom, + rangeTo, + refreshPaused, + refreshInterval, + width = 'restricted', + onTimeRangeRefresh, +}: DatePickerProps) { + const { updateTimeRange, updateRefreshInterval } = useDatePickerContext(); + + const timePickerQuickRanges = useKibanaUISettings( + UI_SETTINGS.TIMEPICKER_QUICK_RANGES + ); + + const commonlyUsedRanges = timePickerQuickRanges.map(({ from, to, display }) => ({ + start: from, + end: to, + label: display, + })); + + function onRefreshChange({ + isPaused, + refreshInterval: interval, + }: { + isPaused: boolean; + refreshInterval: number; + }) { + updateRefreshInterval({ isPaused, interval }); + } + + const onRefresh = useCallback( + (newRange: { start: string; end: string }) => { + if (onTimeRangeRefresh) { + onTimeRangeRefresh(newRange); + } + updateTimeRange(newRange); + }, + [onTimeRangeRefresh, updateTimeRange] + ); + + return ( + + ); +} + +// eslint-disable-next-line import/no-default-export +export default DatePicker; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/date_picker/typings.ts b/x-pack/plugins/exploratory_view/public/components/shared/date_picker/typings.ts new file mode 100644 index 0000000000000..ed41716bfb78a --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/date_picker/typings.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface TimePickerQuickRange { + from: string; + to: string; + display: string; +} + +export interface TimePickerRefreshInterval { + pause: boolean; + value: number; +} + +export interface TimePickerTimeDefaults { + from: string; + to: string; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/README.md b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/README.md new file mode 100644 index 0000000000000..6aea217a1aaa8 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/README.md @@ -0,0 +1,161 @@ +# Exploratory view component + +This component is used in observability plugin to show lens embeddable based observability visualizations. +The view is populated using configs stored as json within the view for each data type. + +This readme file contains few of the concepts being used in the component. + +Basic workflow for how exploratory view works, it looks like this + + +![Exploratory view workflow](https://i.imgur.com/Kgyfd29.png) + + +## Report Type + +The exploratory view report type controls how the data is visualized in the lens embeddable. The report type defines a set of constraints over the x and y axis. For example, the `kpi-over-time` report type is a time series chart type that plots key performance indicators over time, while the `data-distribution` chart plots the percentage of documents over key performance indicators. Current available data types can be found at `exploratory_view/configurations/constants`. + +Each report type has one or more available visualizations to plot data from one or more data types. + +## Data Types + +Each available visualization is backed by a data type. A data type consists of a set of configuration for displaying domain-specific visualizations for observability data. Some example data types include apm, metrics, and logs. + +For each respective data type, we fetch index pattern string from the app plugin contract, leveraging existing hasData API we have to return the index pattern string as well as a `hasData` boolean from each plugin. + +In most cases, there will be a 1-1 relation between apps and data types. + +### Observability `dataViews` + +Once we have index pattern string for each data type, a respective `dataView` is created. If there is an existing dataView for an index pattern, we will fetch and reuse it. + +After the dataView is created we also set field formats to promote human-readability. For example, we set format for monitor duration field, which is monitor.duration.us, from microseconds to seconds for browser monitors. + +### Visualization Configuration + +Each data type may have one or more visualization configurations. The data type to visualization configuration can be found in [`exploratory_view/obs_exploratory_view`](https://github.com/elastic/kibana/blob/main/x-pack/plugins/observability/public/components/shared/exploratory_view/obsv_exploratory_view.tsx#L86) + +Each visualization configuration is mapped to a single report type. + +Visualization configurations are used to define the UI we display for each report type and data type combination in the series builder. +Visualization configuration define UI options and display, including available `metrics`, available `filters`, available `breakdown` options, definitions for human-readable `labels`, and more. +The configuration also defines any custom base filters, which usually get pushed to a query, but are not displayed on the UI. You can also set more custom options on the configuration like colors which get used while rendering the chart. + +Visualization configuration can be found at [`exploratory_view/configurations`](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations), where each data type typically has a folder that holds one or more visualization configurations. + +The configuration defined ultimately influences the lens embeddable attributes which get pushed to lens embeddable, rendering the chart. + +Some options in configuration are: + +#### Definition fields +They are also filters, but usually main filters, around which usually app UI is based. +For apm, it could be service name and for uptime, monitor name. + +#### Filters +You can define base filters in kql form or data plugin filter format, filters are strongly typed. + +#### Breakdown fields +List of fields from an index pattern, UI will use this to populate breakdown option select. + +#### Labels +You can set key/value map for your field labels. UI will use these to set labels for data view fields. + +Sample config +``` +{ + reportType: ReportTypes.KPI, + defaultSeriesType: 'bar_stacked', + xAxisColumn: { + sourceField: '@timestamp', + }, + yAxisColumns: [ + { + sourceField: REPORT_METRIC_FIELD, + operationType: 'median', + }, + ], + hasOperationType: false, + filterFields: ['observer.geo.name', 'monitor.type', 'tags'], // these fields get's resolved from relevant dataView + breakdownFields: [ + 'observer.geo.name', + 'monitor.type', + 'monitor.name', + PERCENTILE, + ], // these fields get's resolved from relevant dataView + baseFilters: [], + palette: { type: 'palette', name: 'status' }, + definitionFields: [ + { field: 'monitor.name', nested: SYNTHETICS_STEP_NAME, singleSelection: true }, + { field: 'url.full', filters: buildExistsFilter('summary.up', dataView) }, + ], + metricOptions: [ + { + label: MONITORS_DURATION_LABEL, + field: 'monitor.duration.us', + columnType: OPERATION_COLUMN, + } + ], + labels: { ...FieldLabels, [SUMMARY_UP]: UP_LABEL, [SUMMARY_DOWN]: DOWN_LABEL }, + } +``` + + + +## Lens Embeddable + +Lens embeddable is what actually renders the chart in exploratory view. + +Exploratory view generates the lens embeddable attributes as json and pass it to the component. + +Based on configuration, exploratory view generates layers and columns. + +Add a link to lens embeddable readme + +#### Example +A simple usage of lens embeddable example and playground options +[embedded_lens_example](../../../../../../examples/embedded_lens_example) + +## Exploratory view Embeddable + +The primary purpose of the exploratory view is to embed it in observability solutions like uptime to replace +existing static visualizations, + +For that purpose, all the configuration options we define in the exploratory view can be used as an embeddable +via a component that is exposed using observability plugin contract, +usage looks like this + +`const ExploratoryViewComponent = props.plugins.observability.ExploratoryViewEmbeddable; +` + +``` + +``` + +there is an example in kibana example which you can view using +`yarn start --run-examples` and view the code at [Exploratory view embeddable](../../../../../../examples/exploratory_view_example) + +#### Example +A simple usage of lens embeddable example and playground options, run kibana with +`yarn start --run-example` to see this example in action +source code is defined at [embedded_lens_example](../../../../../../examples/embedded_lens_example) \ No newline at end of file diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/action_menu/action_menu.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/action_menu/action_menu.test.tsx new file mode 100644 index 0000000000000..566c271381125 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/action_menu/action_menu.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '../../rtl_helpers'; +import { fireEvent, screen } from '@testing-library/dom'; +import React from 'react'; +import { sampleAttribute } from '../../configurations/test_data/sample_attribute'; +import * as pluginHook from '../../../../../hooks/use_plugin_context'; +import { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import { ExpViewActionMenuContent } from './action_menu'; +import { noCasesPermissions as mockUseGetCasesPermissions } from '../../../../../utils/cases_permissions'; + +jest.spyOn(pluginHook, 'usePluginContext').mockReturnValue({ + appMountParameters: { + setHeaderActionMenu: jest.fn(), + }, +} as any); + +jest.mock('../../../../../hooks/use_get_user_cases_permissions', () => ({ + useGetUserCasesPermissions: jest.fn(() => mockUseGetCasesPermissions()), +})); + +describe('Action Menu', function () { + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should be able to click open in lens', async function () { + const { findByText, core } = render( + + ); + + expect(await screen.findByText('Open in Lens')).toBeInTheDocument(); + + fireEvent.click(await findByText('Open in Lens')); + + expect(core.lens?.navigateToPrefilledEditor).toHaveBeenCalledTimes(1); + expect(core.lens?.navigateToPrefilledEditor).toHaveBeenCalledWith( + { + id: '', + attributes: sampleAttribute, + timeRange: { to: 'now', from: 'now-10m' }, + }, + { + openInNewTab: true, + } + ); + }); + + it('should be able to click save', async function () { + const { findByText } = render( + + ); + + expect(await screen.findByText('Save')).toBeInTheDocument(); + + fireEvent.click(await findByText('Save')); + + expect(await screen.findByText('Lens Save Modal Component')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/action_menu/action_menu.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/action_menu/action_menu.tsx new file mode 100644 index 0000000000000..fdc97f4999fcd --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/action_menu/action_menu.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { LensEmbeddableInput, TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { EmbedAction } from '../../header/embed_action'; +import { ObservabilityAppServices } from '../../../../../application/types'; +import { AddToCaseAction } from '../../header/add_to_case_action'; + +export function ExpViewActionMenuContent({ + timeRange, + lensAttributes, +}: { + timeRange?: { from: string; to: string }; + lensAttributes: TypedLensByValueInput['attributes'] | null; +}) { + const kServices = useKibana().services; + + const { lens, isDev } = kServices; + + const [isSaveOpen, setIsSaveOpen] = useState(false); + + const LensSaveModalComponent = lens.SaveModalComponent; + + return ( + <> + + {isDev && ( + + + + )} + {timeRange && ( + + + + )} + + { + if (lensAttributes) { + lens.navigateToPrefilledEditor( + { + id: '', + timeRange, + attributes: lensAttributes, + }, + { + openInNewTab: true, + } + ); + } + }} + > + {i18n.translate('xpack.exploratoryView.expView.heading.openInLens', { + defaultMessage: 'Open in Lens', + })} + + + + { + if (lensAttributes) { + setIsSaveOpen(true); + } + }} + size="s" + > + {i18n.translate('xpack.exploratoryView.expView.heading.saveLensVisualization', { + defaultMessage: 'Save', + })} + + + + + {isSaveOpen && lensAttributes && ( + setIsSaveOpen(false)} + // if we want to do anything after the viz is saved + // right now there is no action, so an empty function + onSave={() => {}} + /> + )} + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/action_menu/index.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/action_menu/index.tsx new file mode 100644 index 0000000000000..63ce9162adefc --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/action_menu/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import { ExpViewActionMenuContent } from './action_menu'; +import HeaderMenuPortal from '../../../header_menu_portal'; +import { useExploratoryView } from '../../contexts/exploratory_view_config'; + +interface Props { + timeRange?: { from: string; to: string }; + lensAttributes: TypedLensByValueInput['attributes'] | null; +} +export function ExpViewActionMenu(props: Props) { + const { setHeaderActionMenu, theme$ } = useExploratoryView(); + + return ( + + + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/date_range_picker.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/date_range_picker.tsx new file mode 100644 index 0000000000000..481d7d14bed6f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/date_range_picker.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiDatePicker, EuiDatePickerRange } from '@elastic/eui'; +import { Moment } from 'moment'; +import DateMath from '@kbn/datemath'; +import { i18n } from '@kbn/i18n'; +import { useUiSetting } from '@kbn/kibana-react-plugin/public'; +import { useSeriesStorage } from '../hooks/use_series_storage'; +import { SeriesUrl } from '../types'; +import { ReportTypes } from '../configurations/constants'; + +export const parseRelativeDate = (date: string, options = {}): Moment | void => { + return DateMath.parse(date, options)!; +}; + +export function DateRangePicker({ seriesId, series }: { seriesId: number; series: SeriesUrl }) { + const { firstSeries, setSeries, reportType } = useSeriesStorage(); + const dateFormat = useUiSetting('dateFormat'); + + const seriesFrom = series.time?.from; + const seriesTo = series.time?.to; + + const { from: mainFrom, to: mainTo } = firstSeries!.time; + + const startDate = parseRelativeDate(seriesFrom ?? mainFrom)!; + const endDate = parseRelativeDate(seriesTo ?? mainTo, { roundUp: true })!; + + const getTotalDuration = () => { + const mainStartDate = parseRelativeDate(mainFrom)!; + const mainEndDate = parseRelativeDate(mainTo, { roundUp: true })!; + return mainEndDate.diff(mainStartDate, 'millisecond'); + }; + + const onStartChange = (newStartDate: Moment) => { + if (reportType === ReportTypes.KPI) { + const totalDuration = getTotalDuration(); + const newFrom = newStartDate.toISOString(); + const newTo = newStartDate.add(totalDuration, 'millisecond').toISOString(); + + setSeries(seriesId, { + ...series, + time: { from: newFrom, to: newTo }, + }); + } else { + const newFrom = newStartDate.toISOString(); + + setSeries(seriesId, { + ...series, + time: { from: newFrom, to: seriesTo }, + }); + } + }; + + const onEndChange = (newEndDate: Moment) => { + if (reportType === ReportTypes.KPI) { + const totalDuration = getTotalDuration(); + const newTo = newEndDate.toISOString(); + const newFrom = newEndDate.subtract(totalDuration, 'millisecond').toISOString(); + + setSeries(seriesId, { + ...series, + time: { from: newFrom, to: newTo }, + }); + } else { + const newTo = newEndDate.toISOString(); + + setSeries(seriesId, { + ...series, + time: { from: seriesFrom, to: newTo }, + }); + } + }; + + return ( + endDate} + aria-label={i18n.translate('xpack.exploratoryView.expView.dateRanger.startDate', { + defaultMessage: 'Start date', + })} + dateFormat={dateFormat.replace('ss.SSS', 'ss')} + showTimeSelect + popoverPlacement="rightCenter" + /> + } + endDateControl={ + endDate} + aria-label={i18n.translate('xpack.exploratoryView.expView.dateRanger.endDate', { + defaultMessage: 'End date', + })} + dateFormat={dateFormat.replace('ss.SSS', 'ss')} + showTimeSelect + popoverPlacement="rightCenter" + /> + } + /> + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/empty_view.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/empty_view.tsx new file mode 100644 index 0000000000000..cf4d3dc1e6e15 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/empty_view.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { isEmpty } from 'lodash'; +import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiSpacer, EuiText } from '@elastic/eui'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { LOADING_VIEW } from '../series_editor/series_editor'; +import { ReportViewType, SeriesUrl } from '../types'; + +export function EmptyView({ + loading, + series, + reportType, +}: { + loading: boolean; + series?: SeriesUrl; + reportType: ReportViewType; +}) { + const { dataType, reportDefinitions } = series ?? {}; + + let emptyMessage = EMPTY_LABEL; + + if (dataType) { + if (reportType) { + if (isEmpty(reportDefinitions)) { + emptyMessage = CHOOSE_REPORT_DEFINITION; + } + } else { + emptyMessage = SELECT_REPORT_TYPE_BELOW; + } + } else { + emptyMessage = SELECTED_DATA_TYPE_FOR_REPORT; + } + + if (!series) { + emptyMessage = i18n.translate('xpack.exploratoryView.expView.seriesEditor.notFound', { + defaultMessage: 'No series found. Please add a series.', + }); + } + + return ( + + {loading && ( + + )} + + + + {loading ? LOADING_VIEW : emptyMessage} + + + + ); +} + +const Wrapper = styled.div` + text-align: center; + position: relative; +`; + +const FlexGroup = styled(EuiFlexGroup)` + height: 100%; +`; + +export const EMPTY_LABEL = i18n.translate('xpack.exploratoryView.expView.seriesBuilder.emptyview', { + defaultMessage: 'Nothing to display.', +}); + +export const CHOOSE_REPORT_DEFINITION = i18n.translate( + 'xpack.exploratoryView.expView.seriesBuilder.emptyReportDefinition', + { + defaultMessage: 'Select a report definition to create a visualization.', + } +); + +export const SELECT_REPORT_TYPE_BELOW = i18n.translate( + 'xpack.exploratoryView.expView.seriesBuilder.selectReportType.empty', + { + defaultMessage: 'Select a report type to create a visualization.', + } +); + +const SELECTED_DATA_TYPE_FOR_REPORT = i18n.translate( + 'xpack.exploratoryView.expView.reportType.selectDataType', + { defaultMessage: 'Select a data type to create a visualization.' } +); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/filter_label.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/filter_label.test.tsx new file mode 100644 index 0000000000000..f5c65eee26597 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/filter_label.test.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { mockAppDataView, mockDataView, mockUxSeries, render } from '../rtl_helpers'; +import { FilterLabel } from './filter_label'; +import * as useSeriesHook from '../hooks/use_series_filters'; +import { buildFilterLabel } from '../../filter_value_label/filter_value_label'; +import 'jest-canvas-mock'; + +jest.setTimeout(10 * 1000); + +describe('FilterLabel', function () { + mockAppDataView(); + + const invertFilter = jest.fn(); + jest.spyOn(useSeriesHook, 'useSeriesFilters').mockReturnValue({ + invertFilter, + } as any); + + it('should render properly', async function () { + render( + + ); + + await waitFor(async () => { + expect(await screen.findByText('elastic-co')).toBeInTheDocument(); + expect(await screen.findByText('elastic-co')).toBeInTheDocument(); + expect(await screen.findByText(/web application:/i)).toBeInTheDocument(); + expect(await screen.findByTitle('Delete Web Application: elastic-co')).toBeInTheDocument(); + }); + }); + + it('should delete filter', async function () { + const removeFilter = jest.fn(); + render( + + ); + + fireEvent.click(await screen.findByLabelText('Filter actions')); + + fireEvent.click(await screen.findByTestId('deleteFilter')); + expect(removeFilter).toHaveBeenCalledTimes(1); + expect(removeFilter).toHaveBeenCalledWith('service.name', 'elastic-co', false); + }); + + it('should invert filter', async function () { + const removeFilter = jest.fn(); + render( + + ); + + fireEvent.click(await screen.findByLabelText('Filter actions')); + + fireEvent.click(await screen.findByTestId('negateFilter')); + expect(invertFilter).toHaveBeenCalledTimes(1); + expect(invertFilter).toHaveBeenCalledWith({ + field: 'service.name', + negate: false, + value: 'elastic-co', + }); + }); + + it('should display invert filter', async function () { + render( + + ); + + expect(await screen.findByText('elastic-co')).toBeInTheDocument(); + expect(await screen.findByText(/web application:/i)).toBeInTheDocument(); + expect(await screen.findByTitle('Delete NOT Web Application: elastic-co')).toBeInTheDocument(); + expect( + await screen.findByRole('button', { + name: /delete not web application: elastic-co/i, + }) + ).toBeInTheDocument(); + }); + + it('should build filter meta', function () { + expect( + buildFilterLabel({ + field: 'user_agent.name', + label: 'Browser family', + dataView: mockDataView, + value: 'Firefox', + negate: false, + }) + ).toEqual({ + meta: { + alias: null, + disabled: false, + index: 'apm-*', + key: 'Browser family', + negate: false, + type: 'phrase', + value: 'Firefox', + }, + query: { + match_phrase: { + 'user_agent.name': 'Firefox', + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/filter_label.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/filter_label.tsx new file mode 100644 index 0000000000000..8b6343119f121 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/filter_label.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { useSeriesFilters } from '../hooks/use_series_filters'; +import { FilterValueLabel } from '../../filter_value_label/filter_value_label'; +import { SeriesUrl } from '../types'; + +interface Props { + field: string; + label: string; + value: string | Array; + seriesId: number; + series: SeriesUrl; + negate: boolean; + definitionFilter?: boolean; + dataView: DataView; + removeFilter: (field: string, value: string | Array, notVal: boolean) => void; +} + +export function FilterLabel({ + label, + seriesId, + series, + field, + value, + negate, + dataView, + removeFilter, + definitionFilter, +}: Props) { + const { invertFilter } = useSeriesFilters({ seriesId, series }); + + return dataView ? ( + { + if (!definitionFilter) invertFilter(val); + }} + field={field} + value={value} + negate={negate} + label={label} + /> + ) : null; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/series_color_picker.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/series_color_picker.tsx new file mode 100644 index 0000000000000..4324ff7a569bd --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/series_color_picker.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { + EuiColorPicker, + EuiFormRow, + EuiIcon, + EuiPopover, + EuiToolTip, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useTheme } from '../../../../hooks/use_theme'; +import { useSeriesStorage } from '../hooks/use_series_storage'; +import { SeriesUrl } from '../types'; + +export function SeriesColorPicker({ seriesId, series }: { seriesId: number; series: SeriesUrl }) { + const theme = useTheme(); + + const { setSeries } = useSeriesStorage(); + + const [isOpen, setIsOpen] = useState(false); + + const onChange = (colorN: string) => { + setSeries(seriesId, { ...series, color: colorN }); + }; + + const color = + series.color ?? (theme.eui as unknown as Record)[`euiColorVis${seriesId}`]; + + const button = ( + + setIsOpen((prevState) => !prevState)} + flush="both" + > + + + + ); + + return ( + setIsOpen(false)}> + + + + + ); +} + +const PICK_A_COLOR_LABEL = i18n.translate( + 'xpack.exploratoryView.overview.exploratoryView.pickColor', + { + defaultMessage: 'Pick a color', + } +); + +const EDIT_SERIES_COLOR_LABEL = i18n.translate( + 'xpack.exploratoryView.overview.exploratoryView.editSeriesColor', + { + defaultMessage: 'Edit color for series', + } +); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/series_date_picker/index.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/series_date_picker/index.tsx new file mode 100644 index 0000000000000..0567f5cdaa1d5 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/series_date_picker/index.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiSuperDatePicker } from '@elastic/eui'; +import React from 'react'; + +import { useHasData } from '../../../../../hooks/use_has_data'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { useQuickTimeRanges } from '../../../../../hooks/use_quick_time_ranges'; +import { SeriesUrl } from '../../types'; +import { ReportTypes } from '../../configurations/constants'; + +export interface TimePickerTime { + from: string; + to: string; +} + +export interface TimePickerQuickRange extends TimePickerTime { + display: string; +} + +interface Props { + seriesId: number; + series: SeriesUrl; +} + +export function SeriesDatePicker({ series, seriesId }: Props) { + const { onRefreshTimeRange } = useHasData(); + + const commonlyUsedRanges = useQuickTimeRanges(); + + const { setSeries, reportType, allSeries } = useSeriesStorage(); + + function onTimeChange({ start, end }: { start: string; end: string }) { + onRefreshTimeRange?.(); + if (reportType === ReportTypes.KPI) { + allSeries.forEach((currSeries, seriesIndex) => { + setSeries(seriesIndex, { ...currSeries, time: { from: start, to: end } }); + }); + } else { + setSeries(seriesId, { ...series, time: { from: start, to: end } }); + } + } + + return ( + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/series_date_picker/series_date_picker.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/series_date_picker/series_date_picker.test.tsx new file mode 100644 index 0000000000000..4e6a02c3ddefb --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/series_date_picker/series_date_picker.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mockUseHasData, render } from '../../rtl_helpers'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { SeriesDatePicker } from '.'; + +describe('SeriesDatePicker', function () { + it('should render properly', function () { + const initSeries = { + data: [ + { + name: 'uptime-pings-histogram', + dataType: 'synthetics' as const, + breakdown: 'monitor.status', + time: { from: 'now-30m', to: 'now' }, + }, + ], + }; + const { getByText } = render(, { + initSeries, + }); + + getByText('Last 30 minutes'); + }); + + it('should set series data', async function () { + const initSeries = { + data: [ + { + name: 'uptime-pings-histogram', + dataType: 'synthetics' as const, + breakdown: 'monitor.status', + time: { from: 'now-30m', to: 'now' }, + }, + ], + }; + + const { onRefreshTimeRange } = mockUseHasData(); + const { getByTestId, setSeries } = render( + , + { + initSeries, + } + ); + + await waitFor(function () { + fireEvent.click(getByTestId('superDatePickerToggleQuickMenuButton')); + }); + + fireEvent.click(getByTestId('superDatePickerCommonlyUsed_Today')); + + expect(onRefreshTimeRange).toHaveBeenCalledTimes(1); + + expect(setSeries).toHaveBeenCalledWith(0, { + name: 'uptime-pings-histogram', + breakdown: 'monitor.status', + dataType: 'synthetics', + time: { from: 'now/d', to: 'now/d' }, + }); + expect(setSeries).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/url_search/selectable_url_list.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/url_search/selectable_url_list.test.tsx new file mode 100644 index 0000000000000..c93f985dd9f3f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/url_search/selectable_url_list.test.tsx @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { fireEvent, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import * as fetcherHook from '../../../../../hooks/use_fetcher'; +import { SelectableUrlList } from './selectable_url_list'; +import { I18LABELS } from './translations'; +import { render } from '../../rtl_helpers'; + +describe('SelectableUrlList', () => { + jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ + data: {}, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }); + + const customHistory = createMemoryHistory({ + initialEntries: ['/?searchTerm=blog'], + }); + + function WrappedComponent() { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + return ( + true} + /> + ); + } + + it('it uses search term value from url', () => { + const { getByDisplayValue } = render( + true} + />, + { history: customHistory } + ); + expect(getByDisplayValue('blog')).toBeInTheDocument(); + }); + + it('maintains focus on search input field', () => { + const { getByLabelText } = render( + true} + />, + { history: customHistory } + ); + + const input = getByLabelText(I18LABELS.filterByUrl); + fireEvent.click(input); + + expect(document.activeElement).toBe(input); + }); + + it('hides popover on escape', async () => { + const { getByText, getByLabelText, queryByText } = render(, { + history: customHistory, + }); + + const input = getByLabelText(I18LABELS.filterByUrl); + fireEvent.click(input); + + // wait for title of popover to be present + await waitFor(() => { + expect(getByText(I18LABELS.getSearchResultsLabel(0))).toBeInTheDocument(); + }); + + // escape key + fireEvent.keyDown(input, { + key: 'Escape', + code: 'Escape', + keyCode: 27, + charCode: 27, + }); + + // wait for title of popover to be removed + await waitForElementToBeRemoved(() => queryByText(I18LABELS.getSearchResultsLabel(0))); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/url_search/selectable_url_list.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/url_search/selectable_url_list.tsx new file mode 100644 index 0000000000000..d7422c35ebacf --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/url_search/selectable_url_list.tsx @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { + SetStateAction, + useRef, + useState, + KeyboardEvent, + ReactNode, + FormEventHandler, +} from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPopover, + EuiPopoverTitle, + EuiSelectable, + EuiSelectableMessage, + EuiPopoverFooter, + EuiButton, + EuiButtonIcon, + EuiSelectableOption, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import useEvent from 'react-use/lib/useEvent'; +import classNames from 'classnames'; +import { I18LABELS } from './translations'; + +export type UrlOption = { + meta?: string[]; + isNewWildcard?: boolean; + isWildcard?: boolean; + title: string; +} & EuiSelectableOption; + +export interface SelectableUrlListProps { + data: { + items: UrlOption[]; + total?: number; + }; + loading: boolean; + rowHeight?: number; + onInputChange: (val: string) => void; + onSelectionApply: () => void; + onSelectionChange: (updatedOptions: UrlOption[]) => void; + searchValue: string; + popoverIsOpen: boolean; + initialValue?: string; + setPopoverIsOpen: React.Dispatch>; + renderOption?: (option: UrlOption, searchValue: string) => ReactNode; + hasChanged: () => boolean; +} +export const formatOptions = (options: EuiSelectableOption[]) => { + return options.map((item: EuiSelectableOption) => ({ + title: item.label, + ...item, + className: classNames('euiSelectableTemplateSitewide__listItem', item.className), + })); +}; +export function SelectableUrlList({ + data, + loading, + onInputChange, + onSelectionChange, + onSelectionApply, + searchValue, + popoverIsOpen, + setPopoverIsOpen, + initialValue, + renderOption, + rowHeight, + hasChanged, +}: SelectableUrlListProps) { + const [searchRef, setSearchRef] = useState(null); + + const titleRef = useRef(null); + + const formattedOptions = formatOptions(data.items ?? []); + + const onEnterKey = (evt: KeyboardEvent) => { + if (evt.key.toLowerCase() === 'enter') { + onSelectionApply(); + setPopoverIsOpen(false); + } + }; + + const onInputClick = (e: React.MouseEvent) => { + setPopoverIsOpen(true); + if (searchRef) { + searchRef.focus(); + } + }; + + const onSearchInput: FormEventHandler = (e) => { + onInputChange((e.target as HTMLInputElement).value); + setPopoverIsOpen(true); + }; + + const closePopover = () => { + setPopoverIsOpen(false); + }; + + // @ts-ignore - not sure, why it's not working + useEvent('keydown', onEnterKey, searchRef); + useEvent('escape', () => setPopoverIsOpen(false), searchRef); + + const loadingMessage = ( + + +
+

{I18LABELS.loadingResults}

+
+ ); + + const emptyMessage = ( + +

{I18LABELS.noResults}

+
+ ); + + const titleText = searchValue + ? I18LABELS.getSearchResultsLabel(data?.total ?? 0) + : I18LABELS.topPages; + + function PopOverTitle() { + return ( + + + + {loading ? : titleText} + + + closePopover()} + aria-label={i18n.translate('xpack.exploratoryView.search.url.close', { + defaultMessage: 'Close', + })} + iconType={'cross'} + /> + + + + ); + } + + return ( + + {(list, search) => ( + +
+ + {list} + + + + { + onSelectionApply(); + closePopover(); + }} + isDisabled={!hasChanged()} + > + {i18n.translate('xpack.exploratoryView.apply.label', { + defaultMessage: 'Apply', + })} + + + + +
+
+ )} +
+ ); +} + +// eslint-disable-next-line import/no-default-export +export default SelectableUrlList; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/url_search/translations.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/url_search/translations.ts new file mode 100644 index 0000000000000..9aa2eca28d868 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/url_search/translations.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const I18LABELS = { + filterByUrl: i18n.translate('xpack.exploratoryView.filters.filterByUrl', { + defaultMessage: 'Filter by URL', + }), + getSearchResultsLabel: (total: number) => + i18n.translate('xpack.exploratoryView.filters.searchResults', { + defaultMessage: '{total} Search results', + values: { total }, + }), + topPages: i18n.translate('xpack.exploratoryView.filters.topPages', { + defaultMessage: 'Top pages', + }), + select: i18n.translate('xpack.exploratoryView.filters.select', { + defaultMessage: 'Select', + }), + url: i18n.translate('xpack.exploratoryView.filters.url', { + defaultMessage: 'Url', + }), + loadingResults: i18n.translate('xpack.exploratoryView.filters.url.loadingResults', { + defaultMessage: 'Loading results', + }), + noResults: i18n.translate('xpack.exploratoryView.filters.url.noResults', { + defaultMessage: 'No results available', + }), +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/url_search/url_search.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/url_search/url_search.tsx new file mode 100644 index 0000000000000..7b5d9ba4f11b0 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/url_search/url_search.tsx @@ -0,0 +1,231 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; +import { isEqual, map } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { SelectableUrlList, UrlOption } from './selectable_url_list'; +import { SeriesConfig, SeriesUrl, UrlFilter } from '../../types'; +import { useUrlSearch } from './use_url_search'; +import { useSeriesFilters } from '../../hooks/use_series_filters'; +import { TRANSACTION_URL } from '../../configurations/constants/elasticsearch_fieldnames'; + +interface Props { + seriesId: number; + seriesConfig: SeriesConfig; + series: SeriesUrl; +} + +const processSelectedItems = (items: UrlOption[]) => { + const urlItems = items.filter(({ isWildcard }) => !isWildcard); + + const wildcardItems = items.filter(({ isWildcard }) => isWildcard); + + const includedItems = map( + urlItems.filter((option) => option.checked === 'on'), + 'label' + ); + + const excludedItems = map( + urlItems.filter((option) => option.checked === 'off'), + 'label' + ); + + // for wild cards we use title since label contains extra information + const includedWildcards = map( + wildcardItems.filter((option) => option.checked === 'on'), + 'title' + ); + + // for wild cards we use title since label contains extra information + const excludedWildcards = map( + wildcardItems.filter((option) => option.checked === 'off'), + 'title' + ); + + return { includedItems, excludedItems, includedWildcards, excludedWildcards }; +}; + +const getWildcardLabel = (wildcard: string) => { + return i18n.translate('xpack.exploratoryView.urlFilter.wildcard', { + defaultMessage: 'Use wildcard *{wildcard}*', + values: { wildcard }, + }); +}; + +export function URLSearch({ series, seriesConfig, seriesId }: Props) { + const [popoverIsOpen, setPopoverIsOpen] = useState(false); + const [query, setQuery] = useState(''); + + const [items, setItems] = useState([]); + + const { values, loading } = useUrlSearch({ + query, + series, + seriesConfig, + seriesId, + }); + + useEffect(() => { + const queryLabel = getWildcardLabel(query); + const currFilter: UrlFilter | undefined = (series.filters ?? []).find( + ({ field }) => field === TRANSACTION_URL + ); + + const { + wildcards = [], + notWildcards = [], + values: currValues = [], + notValues: currNotValues = [], + } = currFilter ?? { field: TRANSACTION_URL }; + + setItems((prevItems) => { + const { includedItems, excludedItems } = processSelectedItems(prevItems); + + const newItems: UrlOption[] = (values ?? []).map((item) => { + if ( + includedItems.includes(item.label) || + wildcards.includes(item.label) || + currValues.includes(item.label) + ) { + return { ...item, checked: 'on', title: item.label }; + } + if ( + excludedItems.includes(item.label) || + notWildcards.includes(item.label) || + currNotValues.includes(item.label) + ) { + return { ...item, checked: 'off', title: item.label, ...item }; + } + return { ...item, title: item.label, checked: undefined }; + }); + + wildcards.forEach((wildcard) => { + newItems.unshift({ + title: wildcard, + label: getWildcardLabel(wildcard), + isWildcard: true, + checked: 'on', + }); + }); + + notWildcards.forEach((wildcard) => { + newItems.unshift({ + title: wildcard, + label: getWildcardLabel(wildcard), + isWildcard: true, + checked: 'off', + }); + }); + + let queryItem: UrlOption | undefined = prevItems.find(({ isNewWildcard }) => isNewWildcard); + if (query) { + if (!queryItem) { + queryItem = { + title: query, + label: queryLabel, + isNewWildcard: true, + isWildcard: true, + }; + newItems.unshift(queryItem); + } + + return [{ ...queryItem, label: queryLabel, title: query }, ...newItems]; + } + + return newItems; + }); + // we don't want to add series in the dependency, for that we have an extra side effect below + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values, loading, query]); + + useEffect(() => { + const currFilter: UrlFilter | undefined = (series.filters ?? []).find( + ({ field }) => field === TRANSACTION_URL + ); + + const { + wildcards = [], + notWildcards = [], + values: currValues = [], + notValues: currNotValues = [], + } = currFilter ?? { field: TRANSACTION_URL }; + + setItems((prevItems) => { + const newItems: UrlOption[] = (prevItems ?? []).map((item) => { + if (currValues.includes(item.label) || wildcards.includes(item.title)) { + return { ...item, checked: 'on' }; + } + + if (currNotValues.includes(item.label) || notWildcards.includes(item.title)) { + return { ...item, checked: 'off' }; + } + return { ...item, checked: undefined }; + }); + + return newItems; + }); + }, [series]); + + const onSelectionChange = (updatedOptions: UrlOption[]) => { + setItems(updatedOptions); + }; + + const { replaceFilter } = useSeriesFilters({ seriesId, series }); + + const onSelectionApply = () => { + const { includedItems, excludedItems, includedWildcards, excludedWildcards } = + processSelectedItems(items); + + replaceFilter({ + field: TRANSACTION_URL, + values: includedItems, + notValues: excludedItems, + wildcards: includedWildcards, + notWildcards: excludedWildcards, + }); + + setQuery(''); + setPopoverIsOpen(false); + }; + + const hasChanged = () => { + const { includedItems, excludedItems, includedWildcards, excludedWildcards } = + processSelectedItems(items); + const currFilter: UrlFilter | undefined = (series.filters ?? []).find( + ({ field }) => field === TRANSACTION_URL + ); + + const { + wildcards = [], + notWildcards = [], + values: currValues = [], + notValues: currNotValues = [], + } = currFilter ?? { field: TRANSACTION_URL }; + + return ( + !isEqual(includedItems.sort(), currValues.sort()) || + !isEqual(excludedItems.sort(), currNotValues.sort()) || + !isEqual(wildcards.sort(), includedWildcards.sort()) || + !isEqual(notWildcards.sort(), excludedWildcards.sort()) + ); + }; + + return ( + setQuery(val)} + data={{ items, total: items.length }} + onSelectionChange={onSelectionChange} + searchValue={query} + popoverIsOpen={popoverIsOpen} + setPopoverIsOpen={setPopoverIsOpen} + onSelectionApply={onSelectionApply} + hasChanged={hasChanged} + /> + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/url_search/use_url_search.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/url_search/use_url_search.ts new file mode 100644 index 0000000000000..da99720fe94bb --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/components/url_search/use_url_search.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SeriesConfig, SeriesUrl } from '../../types'; +import { TRANSACTION_URL } from '../../configurations/constants/elasticsearch_fieldnames'; +import { useFilterValues } from '../../series_editor/use_filter_values'; + +interface Props { + query?: string; + seriesId: number; + series: SeriesUrl; + seriesConfig: SeriesConfig; +} +export const useUrlSearch = ({ series, query, seriesId, seriesConfig }: Props) => { + const { values, loading } = useFilterValues( + { + series, + seriesId, + field: TRANSACTION_URL, + baseFilters: seriesConfig.baseFilters, + label: seriesConfig.labels[TRANSACTION_URL], + }, + query + ); + + return { values, loading }; +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/alerts_configs/kpi_over_time_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/alerts_configs/kpi_over_time_config.ts new file mode 100644 index 0000000000000..e6f8194f2663f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/alerts_configs/kpi_over_time_config.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { ConfigProps, SeriesConfig } from '../../types'; +import { + FieldLabels, + RECORDS_FIELD, + REPORT_METRIC_FIELD, + REPORT_METRIC_TIMESTAMP, + ReportTypes, +} from '../constants'; + +export function getAlertsKPIConfig({ spaceId }: ConfigProps): SeriesConfig { + return { + reportType: ReportTypes.KPI, + defaultSeriesType: 'line', + seriesTypes: [], + xAxisColumn: { + label: i18n.translate('xpack.exploratoryView.exploratoryView.alerts.alertStarted', { + defaultMessage: 'Timestamp', + }), + dataType: 'date', + operationType: 'date_histogram', + isBucketed: true, + scale: 'interval', + sourceField: REPORT_METRIC_TIMESTAMP, + }, + yAxisColumns: [ + { + sourceField: REPORT_METRIC_FIELD, + operationType: 'count', + }, + ], + hasOperationType: false, + filterFields: ['kibana.alert.rule.name', 'kibana.alert.status'], + breakdownFields: ['kibana.alert.rule.category', 'kibana.alert.status'], + baseFilters: [], + definitionFields: ['kibana.alert.rule.category'], + metricOptions: [ + { + label: 'Total alerts', + field: RECORDS_FIELD, + id: 'Alerts', + columnType: 'unique_count', + timestampField: 'kibana.alert.start', + }, + { + label: 'Recovered alerts', + field: RECORDS_FIELD, + id: 'recovered_alerts', + columnType: 'unique_count', + timestampField: 'kibana.alert.end', + }, + ], + labels: { ...FieldLabels }, + query: { + language: 'kuery', + query: `kibana.space_ids: "${spaceId}"`, + }, + }; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/alerts_configs/single_metric_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/alerts_configs/single_metric_config.ts new file mode 100644 index 0000000000000..ce8b43d176861 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/alerts_configs/single_metric_config.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConfigProps, SeriesConfig } from '../../types'; +import { FieldLabels, RECORDS_FIELD, ReportTypes } from '../constants'; + +export function getAlertsSingleMetricConfig({ spaceId }: ConfigProps): SeriesConfig { + return { + seriesTypes: [], + defaultSeriesType: 'line', + reportType: ReportTypes.SINGLE_METRIC, + xAxisColumn: {}, + yAxisColumns: [ + { + operationType: 'median', + }, + ], + hasOperationType: false, + filterFields: ['kibana.alert.rule.name', 'kibana.alert.status'], + breakdownFields: ['kibana.alert.rule.category', 'kibana.alert.status'], + baseFilters: [], + definitionFields: ['kibana.alert.rule.category'], + metricOptions: [ + { + label: 'Active', + field: RECORDS_FIELD, + id: 'Alerts', + columnType: 'unique_count', + metricStateOptions: { + titlePosition: 'bottom', + }, + emptyAsNull: false, + }, + ], + labels: { ...FieldLabels }, + query: { + language: 'kuery', + query: `kibana.space_ids: "${spaceId}"`, + }, + }; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/apm/field_formats.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/apm/field_formats.ts new file mode 100644 index 0000000000000..5c1afbca2a776 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/apm/field_formats.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FieldFormat } from '../../types'; +import { + METRIC_SYSTEM_CPU_USAGE, + METRIC_SYSTEM_MEMORY_USAGE, + TRANSACTION_DURATION, +} from '../constants/elasticsearch_fieldnames'; + +export const apmFieldFormats: FieldFormat[] = [ + { + field: TRANSACTION_DURATION, + format: { + id: 'duration', + params: { + inputFormat: 'microseconds', + outputFormat: 'asMilliseconds', + outputPrecision: 0, + showSuffix: true, + useShortSuffix: true, + }, + }, + }, + { + field: METRIC_SYSTEM_MEMORY_USAGE, + format: { id: 'bytes', params: {} }, + }, + { + field: METRIC_SYSTEM_CPU_USAGE, + format: { id: 'percent', params: {} }, + }, +]; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/constants.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/constants.ts new file mode 100644 index 0000000000000..a40765d2d64e4 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/constants.ts @@ -0,0 +1,213 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { OperationType } from '@kbn/lens-plugin/public'; +import { DOCUMENT_FIELD_NAME } from '@kbn/lens-plugin/common/constants'; +import { i18n } from '@kbn/i18n'; +import { ReportViewType } from '../../types'; +import { + CLS_FIELD, + FCP_FIELD, + FID_FIELD, + LCP_FIELD, + TBT_FIELD, + TRANSACTION_DURATION, + TRANSACTION_TIME_TO_FIRST_BYTE, +} from './elasticsearch_fieldnames'; +import { + AGENT_HOST_LABEL, + AGENT_TYPE_LABEL, + BACKEND_TIME_LABEL, + BROWSER_FAMILY_LABEL, + BROWSER_VERSION_LABEL, + CLS_LABEL, + CORE_WEB_VITALS_LABEL, + DCL_LABEL, + DEVICE_DISTRIBUTION_LABEL, + DEVICE_LABEL, + ENVIRONMENT_LABEL, + EVENT_DATASET_LABEL, + FCP_LABEL, + FID_LABEL, + HEATMAP_LABEL, + HOST_NAME_LABEL, + KPI_LABEL, + KPI_OVER_TIME_LABEL, + LABELS_FIELD, + LCP_LABEL, + LOCATION_LABEL, + MESSAGE_LABEL, + METRIC_LABEL, + MONITOR_ID_LABEL, + MONITOR_NAME_LABEL, + MONITOR_STATUS_LABEL, + MONITOR_TYPE_LABEL, + MONITORS_DURATION_LABEL, + OBSERVER_LOCATION_LABEL, + OS_LABEL, + PAGE_LOAD_TIME_LABEL, + PERF_DIST_LABEL, + PORT_LABEL, + REQUEST_METHOD, + SERVICE_NAME_LABEL, + SERVICE_TYPE_LABEL, + SINGLE_METRIC_LABEL, + STEP_DURATION_LABEL, + STEP_NAME_LABEL, + TAGS_LABEL, + TBT_LABEL, + URL_LABEL, +} from './labels'; +import { + MONITOR_DURATION_US, + SYNTHETICS_BLOCKED_TIMINGS, + SYNTHETICS_CLS, + SYNTHETICS_CONNECT_TIMINGS, + SYNTHETICS_DCL, + SYNTHETICS_DNS_TIMINGS, + SYNTHETICS_DOCUMENT_ONLOAD, + SYNTHETICS_FCP, + SYNTHETICS_LCP, + SYNTHETICS_RECEIVE_TIMINGS, + SYNTHETICS_SEND_TIMINGS, + SYNTHETICS_SSL_TIMINGS, + SYNTHETICS_STEP_DURATION, + SYNTHETICS_STEP_NAME, + SYNTHETICS_TOTAL_TIMINGS, + SYNTHETICS_WAIT_TIMINGS, +} from './field_names/synthetics'; + +export const DEFAULT_TIME = { from: 'now-1h', to: 'now' }; + +export const RECORDS_FIELD = DOCUMENT_FIELD_NAME; +export const RECORDS_PERCENTAGE_FIELD = 'RecordsPercentage'; +export const FORMULA_COLUMN = 'FORMULA_COLUMN'; + +export const FieldLabels: Record = { + 'user_agent.name': BROWSER_FAMILY_LABEL, + 'user_agent.version': BROWSER_VERSION_LABEL, + 'user_agent.os.name': OS_LABEL, + 'client.geo.country_name': LOCATION_LABEL, + 'user_agent.device.name': DEVICE_LABEL, + 'observer.geo.name': OBSERVER_LOCATION_LABEL, + 'service.name': SERVICE_NAME_LABEL, + 'service.environment': ENVIRONMENT_LABEL, + 'service.type': SERVICE_TYPE_LABEL, + 'event.dataset': EVENT_DATASET_LABEL, + message: MESSAGE_LABEL, + + [LCP_FIELD]: LCP_LABEL, + [FCP_FIELD]: FCP_LABEL, + [TBT_FIELD]: TBT_LABEL, + [FID_FIELD]: FID_LABEL, + [CLS_FIELD]: CLS_LABEL, + + [SYNTHETICS_CLS]: CLS_LABEL, + [SYNTHETICS_DCL]: DCL_LABEL, + [SYNTHETICS_STEP_DURATION]: STEP_DURATION_LABEL, + [SYNTHETICS_LCP]: LCP_LABEL, + [SYNTHETICS_FCP]: FCP_LABEL, + [SYNTHETICS_DOCUMENT_ONLOAD]: PAGE_LOAD_TIME_LABEL, + [TRANSACTION_TIME_TO_FIRST_BYTE]: BACKEND_TIME_LABEL, + [TRANSACTION_DURATION]: PAGE_LOAD_TIME_LABEL, + [SYNTHETICS_CONNECT_TIMINGS]: i18n.translate('xpack.exploratoryView.expView.synthetics.connect', { + defaultMessage: 'Connect', + }), + [SYNTHETICS_DNS_TIMINGS]: i18n.translate('xpack.exploratoryView.expView.synthetics.dns', { + defaultMessage: 'DNS', + }), + [SYNTHETICS_WAIT_TIMINGS]: i18n.translate('xpack.exploratoryView.expView.synthetics.wait', { + defaultMessage: 'Wait', + }), + [SYNTHETICS_SSL_TIMINGS]: i18n.translate('xpack.exploratoryView.expView.synthetics.ssl', { + defaultMessage: 'SSL', + }), + [SYNTHETICS_BLOCKED_TIMINGS]: i18n.translate('xpack.exploratoryView.expView.synthetics.blocked', { + defaultMessage: 'Blocked', + }), + [SYNTHETICS_SEND_TIMINGS]: i18n.translate('xpack.exploratoryView.expView.synthetics.send', { + defaultMessage: 'Send', + }), + [SYNTHETICS_RECEIVE_TIMINGS]: i18n.translate('xpack.exploratoryView.expView.synthetics.receive', { + defaultMessage: 'Receive', + }), + [SYNTHETICS_TOTAL_TIMINGS]: i18n.translate('xpack.exploratoryView.expView.synthetics.total', { + defaultMessage: 'Total', + }), + + 'kibana.alert.rule.category': i18n.translate('xpack.exploratoryView.expView.alerts.category', { + defaultMessage: 'Rule category', + }), + 'kibana.alert.rule.name': i18n.translate('xpack.exploratoryView.expView.alerts.name', { + defaultMessage: 'Alert name', + }), + 'kibana.alert.status': i18n.translate('xpack.exploratoryView.expView.alerts.status', { + defaultMessage: 'Alert status', + }), + + 'monitor.id': MONITOR_ID_LABEL, + 'monitor.status': MONITOR_STATUS_LABEL, + [MONITOR_DURATION_US]: MONITORS_DURATION_LABEL, + [SYNTHETICS_STEP_NAME]: STEP_NAME_LABEL, + + 'agent.hostname': AGENT_HOST_LABEL, + 'agent.type': AGENT_TYPE_LABEL, + 'host.hostname': HOST_NAME_LABEL, + 'monitor.name': MONITOR_NAME_LABEL, + 'monitor.type': MONITOR_TYPE_LABEL, + 'url.port': PORT_LABEL, + 'url.full': URL_LABEL, + tags: TAGS_LABEL, + + // custom + + 'performance.metric': METRIC_LABEL, + 'Business.KPI': KPI_LABEL, + 'http.request.method': REQUEST_METHOD, + percentile: 'Percentile', + LABEL_FIELDS_FILTER: LABELS_FIELD, + LABEL_FIELDS_BREAKDOWN: 'Labels field', +}; + +export const DataViewLabels: Record = { + 'data-distribution': PERF_DIST_LABEL, + 'kpi-over-time': KPI_OVER_TIME_LABEL, + 'core-web-vitals': CORE_WEB_VITALS_LABEL, + 'device-data-distribution': DEVICE_DISTRIBUTION_LABEL, + 'single-metric': SINGLE_METRIC_LABEL, + heatmap: HEATMAP_LABEL, +}; + +export enum ReportTypes { + KPI = 'kpi-over-time', + DISTRIBUTION = 'data-distribution', + CORE_WEB_VITAL = 'core-web-vitals', + DEVICE_DISTRIBUTION = 'device-data-distribution', + SINGLE_METRIC = 'single-metric', + HEATMAP = 'heatmap', +} + +export const USE_BREAK_DOWN_COLUMN = 'USE_BREAK_DOWN_COLUMN'; +export const FILTER_RECORDS = 'FILTER_RECORDS'; +export const TERMS_COLUMN = 'TERMS_COLUMN'; +export const OPERATION_COLUMN = 'operation'; +export const PERCENTILE = 'percentile'; + +export const REPORT_METRIC_FIELD = 'REPORT_METRIC_FIELD'; +export const REPORT_METRIC_TIMESTAMP = 'REPORT_METRIC_FIELD_TIMESTAMP'; + +export const PERCENTILE_RANKS = [ + '99th' as OperationType, + '95th' as OperationType, + '90th' as OperationType, + '75th' as OperationType, + '50th' as OperationType, + '25th' as OperationType, +]; +export const LABEL_FIELDS_FILTER = 'LABEL_FIELDS_FILTER'; +export const LABEL_FIELDS_BREAKDOWN = 'LABEL_FIELDS_BREAKDOWN'; + +export const ENVIRONMENT_ALL = 'ENVIRONMENT_ALL'; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/elasticsearch_fieldnames.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/elasticsearch_fieldnames.ts new file mode 100644 index 0000000000000..35873a31150ac --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/elasticsearch_fieldnames.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const CLOUD = 'cloud'; +export const CLOUD_AVAILABILITY_ZONE = 'cloud.availability_zone'; +export const CLOUD_PROVIDER = 'cloud.provider'; +export const CLOUD_REGION = 'cloud.region'; +export const CLOUD_MACHINE_TYPE = 'cloud.machine.type'; + +export const SERVICE = 'service'; +export const SERVICE_NAME = 'service.name'; +export const SERVICE_ENVIRONMENT = 'service.environment'; +export const SERVICE_FRAMEWORK_NAME = 'service.framework.name'; +export const SERVICE_FRAMEWORK_VERSION = 'service.framework.version'; +export const SERVICE_LANGUAGE_NAME = 'service.language.name'; +export const SERVICE_LANGUAGE_VERSION = 'service.language.version'; +export const SERVICE_RUNTIME_NAME = 'service.runtime.name'; +export const SERVICE_RUNTIME_VERSION = 'service.runtime.version'; +export const SERVICE_NODE_NAME = 'service.node.name'; +export const SERVICE_VERSION = 'service.version'; + +export const AGENT = 'agent'; +export const AGENT_NAME = 'agent.name'; +export const AGENT_VERSION = 'agent.version'; + +export const URL_FULL = 'url.full'; +export const HTTP_REQUEST_METHOD = 'http.request.method'; +export const HTTP_RESPONSE_STATUS_CODE = 'http.response.status_code'; +export const USER_ID = 'user.id'; +export const USER_AGENT_ORIGINAL = 'user_agent.original'; +export const USER_AGENT_NAME = 'user_agent.name'; +export const USER_AGENT_VERSION = 'user_agent.version'; + +export const DESTINATION_ADDRESS = 'destination.address'; + +export const OBSERVER_HOSTNAME = 'observer.hostname'; +export const OBSERVER_VERSION_MAJOR = 'observer.version_major'; +export const OBSERVER_LISTENING = 'observer.listening'; +export const PROCESSOR_EVENT = 'processor.event'; + +export const TRANSACTION_DURATION = 'transaction.duration.us'; +export const TRANSACTION_DURATION_HISTOGRAM = 'transaction.duration.histogram'; +export const TRANSACTION_TYPE = 'transaction.type'; +export const TRANSACTION_RESULT = 'transaction.result'; +export const TRANSACTION_NAME = 'transaction.name'; +export const TRANSACTION_ID = 'transaction.id'; +export const TRANSACTION_SAMPLED = 'transaction.sampled'; +export const TRANSACTION_PAGE_URL = 'transaction.page.url'; +// for transaction metrics +export const TRANSACTION_ROOT = 'transaction.root'; + +export const EVENT_OUTCOME = 'event.outcome'; + +export const TRACE_ID = 'trace.id'; + +export const SPAN_DURATION = 'span.duration.us'; +export const SPAN_TYPE = 'span.type'; +export const SPAN_SUBTYPE = 'span.subtype'; +export const SPAN_SELF_TIME_SUM = 'span.self_time.sum.us'; +export const SPAN_ACTION = 'span.action'; +export const SPAN_NAME = 'span.name'; +export const SPAN_ID = 'span.id'; +export const SPAN_DESTINATION_SERVICE_RESOURCE = 'span.destination.service.resource'; +export const SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT = + 'span.destination.service.response_time.count'; + +export const SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM = + 'span.destination.service.response_time.sum.us'; + +// Parent ID for a transaction or span +export const PARENT_ID = 'parent.id'; + +export const ERROR_GROUP_ID = 'error.grouping_key'; +export const ERROR_CULPRIT = 'error.culprit'; +export const ERROR_LOG_LEVEL = 'error.log.level'; +export const ERROR_LOG_MESSAGE = 'error.log.message'; +export const ERROR_EXC_MESSAGE = 'error.exception.message'; // only to be used in es queries, since error.exception is now an array +export const ERROR_EXC_HANDLED = 'error.exception.handled'; // only to be used in es queries, since error.exception is now an array +export const ERROR_EXC_TYPE = 'error.exception.type'; +export const ERROR_PAGE_URL = 'error.page.url'; + +// METRICS +export const METRIC_SYSTEM_FREE_MEMORY = 'system.memory.actual.free'; +export const METRIC_SYSTEM_MEMORY_USAGE = 'system.memory.usage'; +export const METRIC_SYSTEM_CPU_USAGE = 'system.cpu.usage'; +export const METRIC_SYSTEM_TOTAL_MEMORY = 'system.memory.total'; +export const METRIC_SYSTEM_CPU_PERCENT = 'system.cpu.total.norm.pct'; +export const METRIC_PROCESS_CPU_PERCENT = 'system.process.cpu.total.norm.pct'; +export const METRIC_CGROUP_MEMORY_LIMIT_BYTES = 'system.process.cgroup.memory.mem.limit.bytes'; +export const METRIC_CGROUP_MEMORY_USAGE_BYTES = 'system.process.cgroup.memory.mem.usage.bytes'; + +export const METRIC_JAVA_HEAP_MEMORY_MAX = 'jvm.memory.heap.max'; +export const METRIC_JAVA_HEAP_MEMORY_COMMITTED = 'jvm.memory.heap.committed'; +export const METRIC_JAVA_HEAP_MEMORY_USED = 'jvm.memory.heap.used'; +export const METRIC_JAVA_NON_HEAP_MEMORY_MAX = 'jvm.memory.non_heap.max'; +export const METRIC_JAVA_NON_HEAP_MEMORY_COMMITTED = 'jvm.memory.non_heap.committed'; +export const METRIC_JAVA_NON_HEAP_MEMORY_USED = 'jvm.memory.non_heap.used'; +export const METRIC_JAVA_THREAD_COUNT = 'jvm.thread.count'; +export const METRIC_JAVA_GC_COUNT = 'jvm.gc.count'; +export const METRIC_JAVA_GC_TIME = 'jvm.gc.time'; + +export const LABEL_NAME = 'labels.name'; + +export const HOST = 'host'; +export const HOST_HOSTNAME = 'host.hostname'; +export const HOST_OS_PLATFORM = 'host.os.platform'; +export const CONTAINER_ID = 'container.id'; +export const KUBERNETES = 'kubernetes'; +export const POD_NAME = 'kubernetes.pod.name'; + +export const CLIENT_GEO_COUNTRY_ISO_CODE = 'client.geo.country_iso_code'; +export const CLIENT_GEO_COUNTRY_NAME = 'client.geo.country_name'; + +// RUM Labels +export const TRANSACTION_URL = 'url.full'; +export const CLIENT_GEO = 'client.geo'; +export const USER_AGENT_DEVICE = 'user_agent.device.name'; +export const USER_AGENT_OS = 'user_agent.os.name'; +export const USER_AGENT_OS_VERSION = 'user_agent.os.version'; + +export const TRANSACTION_TIME_TO_FIRST_BYTE = 'transaction.marks.agent.timeToFirstByte'; +export const TRANSACTION_DOM_INTERACTIVE = 'transaction.marks.agent.domInteractive'; + +export const FCP_FIELD = 'transaction.marks.agent.firstContentfulPaint'; +export const LCP_FIELD = 'transaction.marks.agent.largestContentfulPaint'; +export const TBT_FIELD = 'transaction.experience.tbt'; +export const FID_FIELD = 'transaction.experience.fid'; +export const CLS_FIELD = 'transaction.experience.cls'; + +export const PROFILE_ID = 'profile.id'; +export const PROFILE_DURATION = 'profile.duration'; +export const PROFILE_TOP_ID = 'profile.top.id'; +export const PROFILE_STACK = 'profile.stack'; + +export const PROFILE_SAMPLES_COUNT = 'profile.samples.count'; +export const PROFILE_CPU_NS = 'profile.cpu.ns'; +export const PROFILE_WALL_US = 'profile.wall.us'; + +export const PROFILE_ALLOC_OBJECTS = 'profile.alloc_objects.count'; +export const PROFILE_ALLOC_SPACE = 'profile.alloc_space.bytes'; +export const PROFILE_INUSE_OBJECTS = 'profile.inuse_objects.count'; +export const PROFILE_INUSE_SPACE = 'profile.inuse_space.bytes'; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/field_names/infra_logs.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/field_names/infra_logs.ts new file mode 100644 index 0000000000000..b35c6ac2e42dd --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/field_names/infra_logs.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RECORDS_FIELD } from '../constants'; + +export const LOG_RATE = RECORDS_FIELD; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/field_names/infra_metrics.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/field_names/infra_metrics.ts new file mode 100644 index 0000000000000..26683dd2a206e --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/field_names/infra_metrics.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const SYSTEM_CPU_PERCENTAGE_FIELD = 'system.cpu.total.norm.pct'; +export const SYSTEM_MEMORY_PERCENTAGE_FIELD = 'system.memory.used.pct'; +export const DOCKER_CPU_PERCENTAGE_FIELD = 'docker.cpu.total.pct'; +export const K8S_POD_CPU_PERCENTAGE_FIELD = 'kubernetes.pod.cpu.usage.node.pct'; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/field_names/synthetics.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/field_names/synthetics.ts new file mode 100644 index 0000000000000..003be106ffaaa --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/field_names/synthetics.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const MONITOR_DURATION_US = 'monitor.duration.us'; +export const SYNTHETICS_CLS = 'browser.experience.cls'; +export const SYNTHETICS_LCP = 'browser.experience.lcp.us'; +export const SYNTHETICS_FCP = 'browser.experience.fcp.us'; +export const SYNTHETICS_DOCUMENT_ONLOAD = 'browser.experience.load.us'; +export const SYNTHETICS_DCL = 'browser.experience.dcl.us'; +export const SYNTHETICS_STEP_NAME = 'synthetics.step.name.keyword'; +export const SYNTHETICS_STEP_DURATION = 'synthetics.step.duration.us'; + +export const SYNTHETICS_DNS_TIMINGS = 'synthetics.payload.timings.dns'; +export const SYNTHETICS_SSL_TIMINGS = 'synthetics.payload.timings.ssl'; +export const SYNTHETICS_BLOCKED_TIMINGS = 'synthetics.payload.timings.blocked'; +export const SYNTHETICS_CONNECT_TIMINGS = 'synthetics.payload.timings.connect'; +export const SYNTHETICS_RECEIVE_TIMINGS = 'synthetics.payload.timings.receive'; +export const SYNTHETICS_SEND_TIMINGS = 'synthetics.payload.timings.send'; +export const SYNTHETICS_WAIT_TIMINGS = 'synthetics.payload.timings.wait'; +export const SYNTHETICS_TOTAL_TIMINGS = 'synthetics.payload.timings.total'; + +export const NETWORK_TIMINGS_FIELDS = [ + SYNTHETICS_DNS_TIMINGS, + SYNTHETICS_SSL_TIMINGS, + SYNTHETICS_BLOCKED_TIMINGS, + SYNTHETICS_CONNECT_TIMINGS, + SYNTHETICS_RECEIVE_TIMINGS, + SYNTHETICS_SEND_TIMINGS, + SYNTHETICS_WAIT_TIMINGS, + SYNTHETICS_TOTAL_TIMINGS, +]; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/index.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/index.ts new file mode 100644 index 0000000000000..63661f0d5a996 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './constants'; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/labels.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/labels.ts new file mode 100644 index 0000000000000..419fcfe39b991 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/labels.ts @@ -0,0 +1,417 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const BROWSER_FAMILY_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.browserFamily', + { + defaultMessage: 'Browser family', + } +); +export const BROWSER_VERSION_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.browserVersion', + { + defaultMessage: 'Browser version', + } +); + +export const OS_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.os', { + defaultMessage: 'Operating system', +}); +export const LOCATION_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.location', { + defaultMessage: 'Location', +}); + +export const DEVICE_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.device', { + defaultMessage: 'Device', +}); + +export const OBSERVER_LOCATION_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.obsLocation', + { + defaultMessage: 'Observer location', + } +); + +export const SERVICE_NAME_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.serviceName', + { + defaultMessage: 'Service name', + } +); + +export const SERVICE_TYPE_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.serviceType', + { + defaultMessage: 'Service type', + } +); + +export const ENVIRONMENT_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.environment', + { + defaultMessage: 'Environment', + } +); + +export const EVENT_DATASET_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.eventDataset', + { + defaultMessage: 'Dataset', + } +); + +export const LCP_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.lcp', { + defaultMessage: 'Largest contentful paint', +}); + +export const FCP_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.fcp', { + defaultMessage: 'First contentful paint', +}); + +export const TBT_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.tbt', { + defaultMessage: 'Total blocking time', +}); + +export const FID_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.fid', { + defaultMessage: 'First input delay', +}); + +export const CLS_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.cls', { + defaultMessage: 'Cumulative layout shift', +}); + +export const NETWORK_TIMINGS_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.networkTimings', + { + defaultMessage: 'Network timings', + } +); + +export const DCL_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.dcl', { + defaultMessage: 'DOM content loaded', +}); + +export const DOCUMENT_ONLOAD_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.onload', + { + defaultMessage: 'Document complete (onLoad)', + } +); + +export const BACKEND_TIME_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.backend', + { + defaultMessage: 'Backend time', + } +); + +export const PAGE_LOAD_TIME_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.pageLoadTime', + { + defaultMessage: 'Page load time', + } +); + +export const PAGE_VIEWS_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.pageViews', + { + defaultMessage: 'Page views', + } +); + +export const PAGES_LOADED_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.pagesLoaded', + { + defaultMessage: 'Pages loaded', + } +); + +export const PINGS_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.pings', { + defaultMessage: 'Pings', +}); + +export const MONITOR_ID_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.monitorId', + { + defaultMessage: 'Monitor Id', + } +); + +export const MONITOR_STATUS_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.monitorStatus', + { + defaultMessage: 'Monitor Status', + } +); + +export const AGENT_HOST_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.agentHost', + { + defaultMessage: 'Agent host', + } +); + +export const AGENT_TYPE_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.agentType', + { + defaultMessage: 'Agent type', + } +); + +export const MESSAGE_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.message', { + defaultMessage: 'Message', +}); + +export const HOST_NAME_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.hostName', + { + defaultMessage: 'Host name', + } +); + +export const MONITOR_NAME_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.monitorName', + { + defaultMessage: 'Monitor name', + } +); + +export const MONITOR_TYPE_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.monitorType', + { + defaultMessage: 'Monitor type', + } +); + +export const PORT_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.port', { + defaultMessage: 'Port', +}); + +export const URL_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.url', { + defaultMessage: 'URL', +}); + +export const TAGS_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.tags', { + defaultMessage: 'Tags', +}); + +export const METRIC_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.metric', { + defaultMessage: 'Metric', +}); +export const LABELS_FIELD = i18n.translate('xpack.exploratoryView.expView.fieldLabels.labels', { + defaultMessage: 'Labels', +}); +export const LABELS_BREAKDOWN = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.chooseField', + { + defaultMessage: 'Labels field', + } +); +export const KPI_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.kpi', { + defaultMessage: 'KPI', +}); + +export const PERF_DIST_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.performanceDistribution', + { + defaultMessage: 'Performance distribution', + } +); + +export const CORE_WEB_VITALS_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.coreWebVitals', + { + defaultMessage: 'Core web vitals', + } +); + +export const DEVICE_DISTRIBUTION_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.deviceDistribution', + { + defaultMessage: 'Device distribution', + } +); + +export const SINGLE_METRIC_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.singleMetric', + { + defaultMessage: 'Single metric', + } +); + +export const HEATMAP_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.heatMap', { + defaultMessage: 'Heatmap', +}); + +export const MOBILE_RESPONSE_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.mobileResponse', + { + defaultMessage: 'Mobile response', + } +); + +export const MEMORY_USAGE_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.memoryUsage', + { + defaultMessage: 'System memory usage', + } +); + +export const KPI_OVER_TIME_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.kpiOverTime', + { + defaultMessage: 'KPI over time', + } +); + +export const MONITORS_DURATION_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.monitorDurationLabel', + { + defaultMessage: 'Monitor duration', + } +); + +export const STEP_DURATION_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.stepDurationLabel', + { + defaultMessage: 'Step duration', + } +); + +export const STEP_NAME_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.stepNameLabel', + { + defaultMessage: 'Step name', + } +); + +export const WEB_APPLICATION_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.webApplication', + { + defaultMessage: 'Web Application', + } +); + +export const UP_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.upPings', { + defaultMessage: 'Up Pings', +}); + +export const DOWN_LABEL = i18n.translate('xpack.exploratoryView.expView.fieldLabels.downPings', { + defaultMessage: 'Down Pings', +}); + +export const CARRIER_NAME = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.carrierName', + { + defaultMessage: 'Carrier Name', + } +); + +export const REQUEST_METHOD = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.requestMethod', + { + defaultMessage: 'Request Method', + } +); + +export const CONNECTION_TYPE = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.connectionType', + { + defaultMessage: 'Connection Type', + } +); +export const HOST_OS = i18n.translate('xpack.exploratoryView.expView.fieldLabels.hostOS', { + defaultMessage: 'Host OS', +}); + +export const SERVICE_VERSION = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.serviceVersion', + { + defaultMessage: 'Service Version', + } +); + +export const OS_PLATFORM = i18n.translate('xpack.exploratoryView.expView.fieldLabels.osPlatform', { + defaultMessage: 'OS Platform', +}); + +export const DEVICE_MODEL = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.deviceModel', + { + defaultMessage: 'Device Model', + } +); + +export const CARRIER_LOCATION = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.carrierLocation', + { + defaultMessage: 'Carrier Location', + } +); + +export const RESPONSE_LATENCY = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.responseLatency', + { + defaultMessage: 'Latency', + } +); + +export const MOBILE_APP = i18n.translate('xpack.exploratoryView.expView.fieldLabels.mobileApp', { + defaultMessage: 'Mobile App', +}); + +export const SYSTEM_MEMORY_USAGE = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.mobile.memoryUsage', + { + defaultMessage: 'System memory usage', + } +); + +export const CPU_USAGE = i18n.translate('xpack.exploratoryView.expView.fieldLabels.cpuUsage', { + defaultMessage: 'CPU usage', +}); + +export const SYSTEM_CPU_USAGE = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.cpuUsage.system', + { + defaultMessage: 'System CPU usage', + } +); + +export const DOCKER_CPU_USAGE = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.cpuUsage.docker', + { + defaultMessage: 'Docker CPU usage', + } +); + +export const K8S_POD_CPU_USAGE = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.cpuUsage.k8sDocker', + { + defaultMessage: 'K8s pod CPU usage', + } +); + +export const TRANSACTIONS_PER_MINUTE = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.transactionPerMinute', + { + defaultMessage: 'Throughput', + } +); + +export const NUMBER_OF_DEVICES = i18n.translate( + 'xpack.exploratoryView.expView.fieldLabels.numberOfDevices', + { + defaultMessage: 'Number of Devices', + } +); + +export const LOG_RATE = i18n.translate('xpack.exploratoryView.expView.fieldLabels.logRate', { + defaultMessage: 'Log rate', +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/url_constants.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/url_constants.ts new file mode 100644 index 0000000000000..df2f31481016d --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/constants/url_constants.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum URL_KEYS { + DATA_TYPE = 'dt', + OPERATION_TYPE = 'op', + SERIES_TYPE = 'st', + BREAK_DOWN = 'bd', + FILTERS = 'ft', + REPORT_DEFINITIONS = 'rdf', + SELECTED_METRIC = 'mt', + HIDDEN = 'h', + NAME = 'n', + COLOR = 'c', + SHOW_PERCENTILE_ANNOTATIONS = 'spa', +} + +export const ALL_VALUES_SELECTED = 'ALL_VALUES'; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/default_configs.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/default_configs.ts new file mode 100644 index 0000000000000..a9c9a525835ce --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/default_configs.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DataView } from '@kbn/data-views-plugin/common'; +import { AppDataType, ReportViewType, SeriesConfig } from '../types'; +import { ReportConfigMap } from '../contexts/exploratory_view_config'; + +interface Props { + reportType: ReportViewType; + dataView: DataView; + dataType: AppDataType; + reportConfigMap: ReportConfigMap; + spaceId?: string; +} + +export const getDefaultConfigs = ({ + reportType, + dataType, + spaceId, + dataView, + reportConfigMap, +}: Props): SeriesConfig => { + let configResult: SeriesConfig | undefined; + + reportConfigMap[dataType]?.some((fn) => { + const config = fn({ dataView, spaceId }); + if (config.reportType === reportType) { + configResult = config; + } + return config.reportType === reportType; + }); + + if (!configResult) { + // not a user facing error, more of a dev focused error + throw new Error( + `No report config provided for dataType: ${dataType} and reportType: ${reportType}` + ); + } + + return configResult; +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/exploratory_view_url.test.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/exploratory_view_url.test.ts new file mode 100644 index 0000000000000..9be8b1adeda86 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/exploratory_view_url.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createExploratoryViewUrl } from './exploratory_view_url'; +import type { AllSeries } from '../../../..'; + +describe('createExploratoryViewUrl', () => { + const testAllSeries = [ + { + dataType: 'synthetics', + seriesType: 'area', + selectedMetricField: 'monitor.duration.us', + time: { + from: 'now-15m', + to: 'now', + }, + breakdown: 'monitor.type', + reportDefinitions: { + 'monitor.name': [], + 'url.full': ['ALL_VALUES'], + }, + name: 'All monitors response duration', + }, + ] as AllSeries; + + describe('handles URL reserved chars', () => { + const urlReservedRegex = /[;,\/?:@&=+$#]/; + + it('encodes &', () => { + const seriesWithAmpersand = [{ ...testAllSeries[0], name: 'Name with &' }]; + const url = createExploratoryViewUrl({ + reportType: 'kpi-over-time', + allSeries: seriesWithAmpersand, + }); + + expect(urlReservedRegex.test(grabRisonQueryFromUrl(url))).toEqual(false); + }); + + it('encodes other reserved chars', () => { + const seriesWithAmpersand = [ + { + ...testAllSeries[0], + name: 'Name with URL reserved chars ;,/?:@&=+$#', + }, + ]; + const url = createExploratoryViewUrl({ + reportType: 'kpi-over-time', + allSeries: seriesWithAmpersand, + }); + + expect(urlReservedRegex.test(grabRisonQueryFromUrl(url))).toEqual(false); + }); + }); +}); + +function grabRisonQueryFromUrl(url: string) { + return url.split('sr=')[1]; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/exploratory_view_url.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/exploratory_view_url.ts new file mode 100644 index 0000000000000..525afc6a0281f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/exploratory_view_url.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import rison from '@kbn/rison'; +import { URL_KEYS } from './constants/url_constants'; +import type { ReportViewType, SeriesUrl } from '../types'; +import type { AllSeries } from '../../../..'; +import type { AllShortSeries } from '../hooks/use_series_storage'; + +export function convertToShortUrl(series: SeriesUrl) { + const { + operationType, + seriesType, + breakdown, + filters, + reportDefinitions, + dataType, + selectedMetricField, + hidden, + name, + color, + ...restSeries + } = series; + + return { + [URL_KEYS.OPERATION_TYPE]: operationType, + [URL_KEYS.SERIES_TYPE]: seriesType, + [URL_KEYS.BREAK_DOWN]: breakdown, + [URL_KEYS.FILTERS]: filters, + [URL_KEYS.REPORT_DEFINITIONS]: reportDefinitions, + [URL_KEYS.DATA_TYPE]: dataType, + [URL_KEYS.SELECTED_METRIC]: selectedMetricField, + [URL_KEYS.HIDDEN]: hidden, + [URL_KEYS.NAME]: name, + [URL_KEYS.COLOR]: color ? escape(color) : undefined, + ...restSeries, + }; +} + +export function createExploratoryViewUrl( + { reportType, allSeries }: { reportType: ReportViewType; allSeries: AllSeries }, + baseHref = '' +) { + const allShortSeries: AllShortSeries = allSeries.map((series) => convertToShortUrl(series)); + + return ( + baseHref + + `/app/exploratory-view/#?reportType=${reportType}&sr=${encodeUriIfNeeded( + rison.encode(allShortSeries) + )}` + ); +} + +/** + * Encodes the uri if it contains characters (`/?@&=+#`). + * It doesn't consider `,` and `:` as they are part of [Rison]{@link https://www.npmjs.com/package/rison-node} syntax. + * + * @param uri Non encoded URI + */ +export function encodeUriIfNeeded(uri: string) { + if (!uri) { + return uri; + } + + if (/[\/?@&=+#]/.test(uri)) { + return encodeURIComponent(uri); + } + + return uri; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/infra_logs/kpi_over_time_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/infra_logs/kpi_over_time_config.ts new file mode 100644 index 0000000000000..aa367739d1d1b --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/infra_logs/kpi_over_time_config.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { ConfigProps, SeriesConfig } from '../../types'; +import { FieldLabels, RECORDS_FIELD, ReportTypes } from '../constants'; +import { LOG_RATE as LOG_RATE_FIELD } from '../constants/field_names/infra_logs'; +import { LOG_RATE as LOG_RATE_LABEL } from '../constants/labels'; + +export function getLogsKPIConfig(configProps: ConfigProps): SeriesConfig { + return { + reportType: ReportTypes.KPI, + defaultSeriesType: 'bar', + seriesTypes: [], + xAxisColumn: { + label: i18n.translate('xpack.exploratoryView.exploratoryView.logs.logRateXAxisLabel', { + defaultMessage: 'Timestamp', + }), + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + }, + yAxisColumns: [ + { + label: i18n.translate('xpack.exploratoryView.exploratoryView.logs.logRateYAxisLabel', { + defaultMessage: 'Log rate per minute', + }), + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: RECORDS_FIELD, + timeScale: 'm', + }, + ], + hasOperationType: false, + filterFields: ['agent.type', 'service.type', 'event.dataset'], + breakdownFields: ['agent.hostname', 'service.type', 'event.dataset'], + baseFilters: [], + definitionFields: ['agent.hostname', 'service.type', 'event.dataset'], + textDefinitionFields: ['message'], + metricOptions: [ + { + label: LOG_RATE_LABEL, + field: RECORDS_FIELD, + id: LOG_RATE_FIELD, + columnType: 'unique_count', + }, + ], + labels: { ...FieldLabels }, + }; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/infra_metrics/field_formats.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/infra_metrics/field_formats.ts new file mode 100644 index 0000000000000..fe473e948e574 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/infra_metrics/field_formats.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FieldFormat } from '../../types'; +import { + SYSTEM_CPU_PERCENTAGE_FIELD, + DOCKER_CPU_PERCENTAGE_FIELD, + K8S_POD_CPU_PERCENTAGE_FIELD, + SYSTEM_MEMORY_PERCENTAGE_FIELD, +} from '../constants/field_names/infra_metrics'; + +export const infraMetricsFieldFormats: FieldFormat[] = [ + { + field: SYSTEM_CPU_PERCENTAGE_FIELD, + format: { + id: 'percent', + params: {}, + }, + }, + { + field: DOCKER_CPU_PERCENTAGE_FIELD, + format: { + id: 'percent', + params: {}, + }, + }, + { + field: K8S_POD_CPU_PERCENTAGE_FIELD, + format: { + id: 'percent', + params: {}, + }, + }, + { + field: SYSTEM_MEMORY_PERCENTAGE_FIELD, + format: { + id: 'percent', + params: {}, + }, + }, +]; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/infra_metrics/kpi_over_time_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/infra_metrics/kpi_over_time_config.ts new file mode 100644 index 0000000000000..56538d252fe3c --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/infra_metrics/kpi_over_time_config.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConfigProps, SeriesConfig } from '../../types'; +import { FieldLabels, OPERATION_COLUMN, REPORT_METRIC_FIELD, ReportTypes } from '../constants'; +import { + SYSTEM_CPU_PERCENTAGE_FIELD, + DOCKER_CPU_PERCENTAGE_FIELD, + K8S_POD_CPU_PERCENTAGE_FIELD, + SYSTEM_MEMORY_PERCENTAGE_FIELD, +} from '../constants/field_names/infra_metrics'; +import { + DOCKER_CPU_USAGE, + K8S_POD_CPU_USAGE, + SYSTEM_CPU_USAGE, + SYSTEM_MEMORY_USAGE, +} from '../constants/labels'; + +export function getMetricsKPIConfig({ dataView }: ConfigProps): SeriesConfig { + return { + reportType: ReportTypes.KPI, + defaultSeriesType: 'area', + seriesTypes: [], + xAxisColumn: { + sourceField: '@timestamp', + }, + yAxisColumns: [ + { + sourceField: REPORT_METRIC_FIELD, + operationType: 'median', + }, + ], + hasOperationType: false, + filterFields: ['agent.type', 'service.type'], + breakdownFields: ['agent.hostname', 'service.type'], + baseFilters: [], + definitionFields: ['agent.hostname', 'service.type'], + metricOptions: [ + { + label: SYSTEM_CPU_USAGE, + field: SYSTEM_CPU_PERCENTAGE_FIELD, + id: SYSTEM_CPU_PERCENTAGE_FIELD, + columnType: OPERATION_COLUMN, + }, + { + label: SYSTEM_MEMORY_USAGE, + field: SYSTEM_MEMORY_PERCENTAGE_FIELD, + id: SYSTEM_MEMORY_PERCENTAGE_FIELD, + columnType: OPERATION_COLUMN, + }, + { + label: DOCKER_CPU_USAGE, + field: DOCKER_CPU_PERCENTAGE_FIELD, + id: DOCKER_CPU_PERCENTAGE_FIELD, + columnType: OPERATION_COLUMN, + }, + { + label: K8S_POD_CPU_USAGE, + field: K8S_POD_CPU_PERCENTAGE_FIELD, + id: K8S_POD_CPU_PERCENTAGE_FIELD, + columnType: OPERATION_COLUMN, + }, + ], + labels: { ...FieldLabels }, + }; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts new file mode 100644 index 0000000000000..bb36de888b407 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts @@ -0,0 +1,685 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LayerConfig, LensAttributes } from './lens_attributes'; +import { mockAppDataView, mockDataView } from '../rtl_helpers'; +import { getDefaultConfigs } from './default_configs'; +import { sampleAttribute } from './test_data/sample_attribute'; + +import { + LCP_FIELD, + TRANSACTION_DURATION, + USER_AGENT_NAME, +} from './constants/elasticsearch_fieldnames'; +import { buildExistsFilter, buildPhrasesFilter } from './utils'; +import { sampleAttributeKpi } from './test_data/sample_attribute_kpi'; +import { RECORDS_FIELD, REPORT_METRIC_FIELD, PERCENTILE_RANKS, ReportTypes } from './constants'; +import { obsvReportConfigMap } from '../obsv_exploratory_view'; +import { sampleAttributeWithReferenceLines } from './test_data/sample_attribute_with_reference_lines'; +import { lensPluginMock } from '@kbn/lens-plugin/public/mocks'; +import { FormulaPublicApi, XYState } from '@kbn/lens-plugin/public'; + +describe('Lens Attribute', () => { + mockAppDataView(); + + const reportViewConfig = getDefaultConfigs({ + reportType: 'data-distribution', + dataType: 'ux', + dataView: mockDataView, + reportConfigMap: obsvReportConfigMap, + }); + + reportViewConfig.baseFilters?.push(...buildExistsFilter('transaction.type', mockDataView)); + + let lnsAttr: LensAttributes; + + const layerConfig: LayerConfig = { + seriesConfig: reportViewConfig, + seriesType: 'line', + operationType: 'count', + dataView: mockDataView, + reportDefinitions: {}, + time: { from: 'now-15m', to: 'now' }, + color: 'green', + name: 'test-series', + selectedMetricField: TRANSACTION_DURATION, + }; + + const lensPluginMockStart = lensPluginMock.createStartContract(); + + let formulaHelper: FormulaPublicApi; + + beforeEach(async () => { + formulaHelper = (await lensPluginMockStart.stateHelperApi()).formula; + lnsAttr = new LensAttributes([layerConfig], reportViewConfig.reportType, formulaHelper); + }); + + it('should return expected json', function () { + expect(lnsAttr.getJSON()).toEqual(sampleAttribute); + }); + + it('should return expected json for kpi report type', function () { + const seriesConfigKpi = getDefaultConfigs({ + reportType: ReportTypes.KPI, + dataType: 'ux', + dataView: mockDataView, + reportConfigMap: obsvReportConfigMap, + }); + + const lnsAttrKpi = new LensAttributes( + [ + { + seriesConfig: seriesConfigKpi, + seriesType: 'line', + operationType: 'count', + dataView: mockDataView, + reportDefinitions: { 'service.name': ['elastic-co'] }, + time: { from: 'now-15m', to: 'now' }, + color: 'green', + name: 'test-series', + selectedMetricField: RECORDS_FIELD, + }, + ], + ReportTypes.KPI + ); + + expect(lnsAttrKpi.getJSON()).toEqual(sampleAttributeKpi); + }); + + it('should return expected json for percentile breakdowns', function () { + const seriesConfigKpi = getDefaultConfigs({ + reportType: ReportTypes.KPI, + dataType: 'ux', + dataView: mockDataView, + reportConfigMap: obsvReportConfigMap, + }); + + const lnsAttrKpi = new LensAttributes( + [ + { + filters: [], + seriesConfig: seriesConfigKpi, + time: { + from: 'now-1h', + to: 'now', + }, + dataView: mockDataView, + name: 'Page load time', + breakdown: 'percentile', + reportDefinitions: {}, + selectedMetricField: 'transaction.duration.us', + color: '#54b399', + }, + ], + ReportTypes.KPI + ); + + expect(lnsAttrKpi.getJSON().state.datasourceStates.formBased.layers.layer0.columns).toEqual({ + 'x-axis-column-layer0': { + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { + interval: 'auto', + includeEmptyRows: true, + }, + scale: 'interval', + sourceField: '@timestamp', + }, + ...PERCENTILE_RANKS.reduce((acc: Record, rank, index) => { + acc[`y-axis-column-${index === 0 ? 'layer' + index + '-0' : index}`] = { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: 'transaction.type: page-load and processor.event: transaction', + }, + isBucketed: false, + label: 'Page load time', + operationType: 'percentile', + params: { + percentile: Number(rank.slice(0, 2)), + }, + scale: 'ratio', + sourceField: 'transaction.duration.us', + }; + return acc; + }, {}), + }); + }); + + it('should return main y axis', function () { + expect(lnsAttr.getMainYAxis(layerConfig, 'layer0', '')).toEqual([ + { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: 'test-series', + operationType: 'formula', + params: { + format: { + id: 'percent', + params: { + decimals: 0, + }, + }, + formula: 'count() / overall_sum(count())', + isFormulaBroken: false, + }, + references: ['y-axis-column-layer0X3'], + }, + ]); + }); + + it('should return expected field type', function () { + expect(JSON.stringify(lnsAttr.getFieldMeta('transaction.type', layerConfig))).toEqual( + JSON.stringify({ + fieldMeta: { + count: 0, + name: 'transaction.type', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + fieldName: 'transaction.type', + }) + ); + }); + + it('should return expected field type for custom field with default value', function () { + expect(JSON.stringify(lnsAttr.getFieldMeta(REPORT_METRIC_FIELD, layerConfig))).toEqual( + JSON.stringify({ + fieldMeta: { + count: 0, + name: 'transaction.duration.us', + type: 'number', + esTypes: ['long'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + fieldName: 'transaction.duration.us', + columnLabel: 'Page load time', + showPercentileAnnotations: true, + }) + ); + }); + + it('should return expected field type for custom field with passed value', function () { + const layerConfig1: LayerConfig = { + seriesConfig: reportViewConfig, + seriesType: 'line', + operationType: 'count', + dataView: mockDataView, + reportDefinitions: { 'performance.metric': [LCP_FIELD] }, + time: { from: 'now-15m', to: 'now' }, + color: 'green', + name: 'test-series', + selectedMetricField: TRANSACTION_DURATION, + }; + + lnsAttr = new LensAttributes([layerConfig1], reportViewConfig.reportType, formulaHelper); + + expect(JSON.stringify(lnsAttr.getFieldMeta(REPORT_METRIC_FIELD, layerConfig1))).toEqual( + JSON.stringify({ + fieldMeta: { + count: 0, + name: TRANSACTION_DURATION, + type: 'number', + esTypes: ['long'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + fieldName: TRANSACTION_DURATION, + columnLabel: 'Page load time', + showPercentileAnnotations: true, + }) + ); + }); + + it('should return expected number range column', function () { + expect(lnsAttr.getNumberRangeColumn('transaction.duration.us', reportViewConfig)).toEqual({ + dataType: 'number', + isBucketed: true, + label: 'Page load time', + operationType: 'range', + params: { + maxBars: 'auto', + ranges: [ + { + from: 0, + label: '', + to: 1000, + }, + ], + type: 'histogram', + }, + scale: 'interval', + sourceField: 'transaction.duration.us', + }); + }); + + it('should return expected number operation column', function () { + expect(lnsAttr.getNumberRangeColumn('transaction.duration.us', reportViewConfig)).toEqual({ + dataType: 'number', + isBucketed: true, + label: 'Page load time', + operationType: 'range', + params: { + maxBars: 'auto', + ranges: [ + { + from: 0, + label: '', + to: 1000, + }, + ], + type: 'histogram', + }, + scale: 'interval', + sourceField: 'transaction.duration.us', + }); + }); + + it('should return expected date histogram column', function () { + expect(lnsAttr.getDateHistogramColumn('@timestamp')).toEqual({ + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { + interval: 'auto', + includeEmptyRows: true, + }, + scale: 'interval', + sourceField: '@timestamp', + }); + }); + + it('should return main x axis', function () { + expect(lnsAttr.getXAxis(layerConfig, 'layer0')).toEqual({ + dataType: 'number', + isBucketed: true, + label: 'Page load time', + operationType: 'range', + params: { + maxBars: 'auto', + ranges: [ + { + from: 0, + label: '', + to: 1000, + }, + ], + type: 'histogram', + }, + scale: 'interval', + sourceField: 'transaction.duration.us', + }); + }); + + it('should hide y axis when there are multiple series', function () { + const lensAttrWithMultiSeries = new LensAttributes( + [layerConfig, layerConfig], + reportViewConfig.reportType, + formulaHelper + ).getJSON() as any; + expect(lensAttrWithMultiSeries.state.visualization.axisTitlesVisibilitySettings).toEqual({ + x: false, + yLeft: false, + yRight: false, + }); + }); + + it('should show y axis when there is a single series', function () { + const lensAttrWithMultiSeries = new LensAttributes( + [layerConfig], + reportViewConfig.reportType, + formulaHelper + ).getJSON() as any; + expect(lensAttrWithMultiSeries.state.visualization.axisTitlesVisibilitySettings).toEqual({ + x: false, + yLeft: true, + yRight: true, + }); + }); + + it('should return first layer', function () { + expect(lnsAttr.getLayers()).toEqual(sampleAttribute.state.datasourceStates.formBased.layers); + }); + + it('should return expected XYState', function () { + expect(lnsAttr.getXyState()).toEqual({ + axisTitlesVisibilitySettings: { x: false, yLeft: true, yRight: true }, + curveType: 'CURVE_MONOTONE_X', + fittingFunction: 'Linear', + gridlinesVisibilitySettings: { x: false, yLeft: true, yRight: true }, + layers: [ + { + accessors: ['y-axis-column-layer0-0'], + layerId: 'layer0', + layerType: 'data', + palette: undefined, + seriesType: 'line', + xAccessor: 'x-axis-column-layer0', + yConfig: [{ color: 'green', forAccessor: 'y-axis-column-layer0-0', axisMode: 'left' }], + }, + { + accessors: [ + '50th-percentile-reference-line-layer0-reference-lines', + '75th-percentile-reference-line-layer0-reference-lines', + '90th-percentile-reference-line-layer0-reference-lines', + '95th-percentile-reference-line-layer0-reference-lines', + '99th-percentile-reference-line-layer0-reference-lines', + ], + layerId: 'layer0-reference-lines', + layerType: 'referenceLine', + yConfig: [ + { + axisMode: 'bottom', + color: '#6092C0', + forAccessor: '50th-percentile-reference-line-layer0-reference-lines', + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + }, + { + axisMode: 'bottom', + color: '#6092C0', + forAccessor: '75th-percentile-reference-line-layer0-reference-lines', + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + }, + { + axisMode: 'bottom', + color: '#6092C0', + forAccessor: '90th-percentile-reference-line-layer0-reference-lines', + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + }, + { + axisMode: 'bottom', + color: '#6092C0', + forAccessor: '95th-percentile-reference-line-layer0-reference-lines', + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + }, + { + axisMode: 'bottom', + color: '#6092C0', + forAccessor: '99th-percentile-reference-line-layer0-reference-lines', + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + }, + ], + }, + ], + legend: { + isVisible: true, + showSingleSeries: true, + position: 'right', + legendSize: 'auto', + shouldTruncate: false, + }, + preferredSeriesType: 'line', + tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, + valueLabels: 'hide', + }); + }); + + it('should not use global filters when there is more than one series', function () { + const multiSeriesLensAttr = new LensAttributes( + [layerConfig, layerConfig], + reportViewConfig.reportType, + formulaHelper + ).getJSON(); + expect(multiSeriesLensAttr.state.query.query).toEqual('transaction.duration.us < 60000000'); + }); + + describe('Layer breakdowns', function () { + it('should return breakdown column', function () { + const layerConfig1: LayerConfig = { + seriesConfig: reportViewConfig, + seriesType: 'line', + operationType: 'count', + dataView: mockDataView, + reportDefinitions: { 'performance.metric': [LCP_FIELD] }, + breakdown: USER_AGENT_NAME, + time: { from: 'now-15m', to: 'now' }, + color: 'green', + name: 'test-series', + selectedMetricField: LCP_FIELD, + }; + + lnsAttr = new LensAttributes([layerConfig1], reportViewConfig.reportType, formulaHelper); + + lnsAttr.getBreakdownColumn({ + layerConfig: layerConfig1, + sourceField: USER_AGENT_NAME, + layerId: 'layer0', + }); + + expect((lnsAttr.visualization as XYState)?.layers).toEqual([ + { + accessors: ['y-axis-column-layer0-0'], + layerId: 'layer0', + layerType: 'data', + palette: undefined, + seriesType: 'line', + splitAccessor: 'breakdown-column-layer0', + xAccessor: 'x-axis-column-layer0', + yConfig: [{ color: 'green', forAccessor: 'y-axis-column-layer0-0', axisMode: 'left' }], + }, + ]); + + expect(lnsAttr.layers.layer0).toEqual({ + columnOrder: [ + 'breakdown-column-layer0', + 'x-axis-column-layer0', + 'y-axis-column-layer0-0', + 'y-axis-column-layer0X0', + 'y-axis-column-layer0X1', + 'y-axis-column-layer0X2', + 'y-axis-column-layer0X3', + ], + columns: { + 'breakdown-column-layer0': { + dataType: 'string', + isBucketed: true, + label: 'Browser family', + operationType: 'terms', + params: { + missingBucket: false, + orderAgg: { + dataType: 'number', + isBucketed: false, + label: 'Count of records', + operationType: 'count', + scale: 'ratio', + sourceField: '___records___', + }, + orderBy: { + type: 'custom', + }, + orderDirection: 'desc', + otherBucket: true, + size: 10, + }, + scale: 'ordinal', + sourceField: 'user_agent.name', + }, + 'x-axis-column-layer0': { + dataType: 'number', + isBucketed: true, + label: 'Largest contentful paint', + operationType: 'range', + params: { + maxBars: 'auto', + ranges: [ + { + from: 0, + label: '', + to: 1000, + }, + ], + type: 'histogram', + }, + scale: 'interval', + sourceField: LCP_FIELD, + }, + 'y-axis-column-layer0-0': { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.type : *', + }, + isBucketed: false, + label: 'test-series', + operationType: 'formula', + params: { + format: { + id: 'percent', + params: { + decimals: 0, + }, + }, + formula: + "count(kql='transaction.type: page-load and processor.event: transaction and transaction.type : *') / overall_sum(count(kql='transaction.type: page-load and processor.event: transaction and transaction.type : *'))", + isFormulaBroken: false, + }, + references: ['y-axis-column-layer0X3'], + }, + 'y-axis-column-layer0X0': { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.type : *', + }, + isBucketed: false, + label: 'Part of Pages loaded', + operationType: 'count', + params: { + emptyAsNull: false, + }, + scale: 'ratio', + sourceField: RECORDS_FIELD, + }, + 'y-axis-column-layer0X1': { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.type : *', + }, + isBucketed: false, + label: 'Part of Pages loaded', + operationType: 'count', + params: { + emptyAsNull: false, + }, + scale: 'ratio', + sourceField: RECORDS_FIELD, + }, + 'y-axis-column-layer0X2': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: 'Part of Pages loaded', + operationType: 'overall_sum', + references: ['y-axis-column-layer0X1'], + scale: 'ratio', + }, + 'y-axis-column-layer0X3': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: 'Part of Pages loaded', + operationType: 'math', + params: { + tinymathAst: { + args: ['y-axis-column-layer0X0', 'y-axis-column-layer0X2'], + location: { + max: 212, + min: 0, + }, + name: 'divide', + text: "count(kql='transaction.type: page-load and processor.event: transaction and transaction.type : *') / overall_sum(count(kql='transaction.type: page-load and processor.event: transaction and transaction.type : *'))", + type: 'function', + }, + }, + references: ['y-axis-column-layer0X0', 'y-axis-column-layer0X2'], + scale: 'ratio', + }, + }, + incompleteColumns: {}, + }); + }); + }); + + describe('Layer Filters', function () { + it('should return expected filters', function () { + reportViewConfig.baseFilters?.push( + ...buildPhrasesFilter('service.name', ['elastic', 'kibana'], mockDataView) + ); + + const layerConfig1: LayerConfig = { + seriesConfig: reportViewConfig, + seriesType: 'line', + operationType: 'count', + dataView: mockDataView, + reportDefinitions: { 'performance.metric': [LCP_FIELD] }, + time: { from: 'now-15m', to: 'now' }, + color: 'green', + name: 'test-series', + selectedMetricField: TRANSACTION_DURATION, + }; + + const filters = lnsAttr.getLayerFilters(layerConfig1, 2); + + expect(filters).toEqual( + '@timestamp >= now-15m and @timestamp <= now and transaction.type: page-load and processor.event: transaction and transaction.type : * and service.name: (elastic or kibana)' + ); + }); + }); + + describe('Reference line layers', function () { + it('should return expected reference lines', function () { + const layerConfig1: LayerConfig = { + seriesConfig: reportViewConfig, + seriesType: 'line', + dataView: mockDataView, + reportDefinitions: {}, + time: { from: 'now-15m', to: 'now' }, + color: 'green', + name: 'test-series', + selectedMetricField: TRANSACTION_DURATION, + }; + + lnsAttr = new LensAttributes([layerConfig1], reportViewConfig.reportType, formulaHelper); + + const attributes = lnsAttr.getJSON(); + + expect(attributes).toEqual(sampleAttributeWithReferenceLines); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes.ts new file mode 100644 index 0000000000000..f7503acf77179 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -0,0 +1,1295 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { capitalize } from 'lodash'; +import { ExistsFilter, isExistsFilter } from '@kbn/es-query'; +import { + AvgIndexPatternColumn, + CardinalityIndexPatternColumn, + CountIndexPatternColumn, + DataType, + DateHistogramIndexPatternColumn, + FieldBasedIndexPatternColumn, + FiltersIndexPatternColumn, + FormulaIndexPatternColumn, + FormulaPublicApi, + LastValueIndexPatternColumn, + MaxIndexPatternColumn, + MedianIndexPatternColumn, + MinIndexPatternColumn, + OperationMetadata, + OperationType, + PercentileIndexPatternColumn, + PersistedIndexPatternLayer, + RangeIndexPatternColumn, + SeriesType, + SumIndexPatternColumn, + TermsIndexPatternColumn, + TypedLensByValueInput, + XYCurveType, + XYState, + YAxisMode, + HeatmapVisualizationState, + MetricState, +} from '@kbn/lens-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { PersistableFilter } from '@kbn/lens-plugin/common'; +import { DataViewSpec } from '@kbn/data-views-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/common/constants'; +import { urlFiltersToKueryString } from '../utils/stringify_kueries'; +import { + FILTER_RECORDS, + FORMULA_COLUMN, + PERCENTILE, + PERCENTILE_RANKS, + RECORDS_FIELD, + RECORDS_PERCENTAGE_FIELD, + REPORT_METRIC_FIELD, + REPORT_METRIC_TIMESTAMP, + ReportTypes, + TERMS_COLUMN, + USE_BREAK_DOWN_COLUMN, +} from './constants'; +import { + ColumnFilter, + MetricOption, + ParamFilter, + SeriesConfig, + SupportedOperations, + TermColumnParamsOrderBy, + UrlFilter, + URLReportDefinition, +} from '../types'; +import { parseRelativeDate } from '../components/date_range_picker'; +import { getDistributionInPercentageColumn } from './lens_columns/overall_column'; + +export function getLayerReferenceName(layerId: string) { + return `indexpattern-datasource-layer-${layerId}`; +} + +export function buildNumberColumn(sourceField: string) { + return { + sourceField, + dataType: 'number' as DataType, + isBucketed: false, + scale: 'ratio' as OperationMetadata['scale'], + }; +} + +export function getPercentileParam(operationType: string) { + return { + percentile: Number(operationType.split('th')[0]), + }; +} + +export const parseCustomFieldName = ( + seriesConfig: SeriesConfig, + selectedMetricField?: string +): + | (Partial & { fieldName: string; columnLabel?: string; columnField?: string }) + | MetricOption[] => { + const metricOptions = seriesConfig.metricOptions ?? []; + + if (selectedMetricField) { + if (metricOptions) { + const currField = metricOptions.find((opt) => { + if ('items' in opt) { + return opt.id === selectedMetricField; + } else { + return opt.field === selectedMetricField || opt.id === selectedMetricField; + } + }); + + if (currField && 'items' in currField) { + const currFieldItem = currField.items.find( + (item) => item.id === selectedMetricField || item.field === selectedMetricField + ); + + if (currFieldItem) { + return { + ...(currFieldItem ?? {}), + fieldName: selectedMetricField, + columnLabel: currFieldItem?.label, + columnField: currFieldItem?.field, + }; + } + + return currField.items; + } + + return { + ...(currField ?? {}), + fieldName: currField?.field ?? selectedMetricField, + columnLabel: currField?.label, + columnField: currField?.field, + }; + } + } + + return { + fieldName: selectedMetricField!, + }; +}; + +type MainYAxisColType = ReturnType; + +export interface LayerConfig { + filters?: UrlFilter[]; + seriesConfig: SeriesConfig; + breakdown?: string; + seriesType?: SeriesType; + operationType?: OperationType; + reportDefinitions: URLReportDefinition; + time: { to: string; from: string }; + dataView: DataView; + selectedMetricField: string; + color: string; + name: string; + showPercentileAnnotations?: boolean; +} + +export class LensAttributes { + layers: Record; + visualization?: XYState | HeatmapVisualizationState | MetricState; + layerConfigs: LayerConfig[] = []; + isMultiSeries?: boolean; + seriesReferenceLines: Record< + string, + { + layerData: PersistedIndexPatternLayer; + layerState: XYState['layers']; + dataView: DataView; + } + >; + globalFilter?: { query: string; language: string }; + reportType: string; + lensFormulaHelper?: FormulaPublicApi; + + constructor( + layerConfigs: LayerConfig[], + reportType: string, + lensFormulaHelper?: FormulaPublicApi + ) { + this.layers = {}; + this.seriesReferenceLines = {}; + this.reportType = reportType; + this.lensFormulaHelper = lensFormulaHelper; + this.isMultiSeries = layerConfigs.length > 1; + + layerConfigs.forEach(({ seriesConfig, operationType }) => { + if (operationType && reportType !== ReportTypes.SINGLE_METRIC) { + seriesConfig.yAxisColumns.forEach((yAxisColumn) => { + if (typeof yAxisColumn.operationType !== undefined) { + yAxisColumn.operationType = + operationType as FieldBasedIndexPatternColumn['operationType']; + } + }); + } + }); + this.layerConfigs = layerConfigs; + this.globalFilter = this.getGlobalFilter(this.isMultiSeries); + + if (reportType === ReportTypes.SINGLE_METRIC) { + return; + } + + this.layers = this.getLayers(); + this.visualization = this.getXyState(); + } + + getGlobalFilter(isMultiSeries: boolean) { + if (isMultiSeries) { + return undefined; + } + const defaultLayerFilter = this.layerConfigs[0].seriesConfig.query + ? ` and ${this.layerConfigs[0].seriesConfig.query.query}` + : ''; + return { + query: `${this.getLayerFilters( + this.layerConfigs[0], + this.layerConfigs.length + )}${defaultLayerFilter}`, + language: 'kuery', + }; + } + + getBreakdownColumn({ + sourceField, + layerId, + layerConfig, + alphabeticOrder, + size = 10, + }: { + sourceField: string; + layerId: string; + layerConfig: LayerConfig; + alphabeticOrder?: boolean; + size?: number; + }): TermsIndexPatternColumn { + const { dataView, seriesConfig, selectedMetricField } = layerConfig; + + const fieldMeta = dataView.getFieldByName(sourceField); + const { metricOptions } = seriesConfig; + const { sourceField: yAxisSourceField } = seriesConfig.yAxisColumns[0]; + + const labels = seriesConfig.labels ?? {}; + + const isFormulaColumn = + Boolean( + metricOptions && + (metricOptions.find((option) => option.id === selectedMetricField) as MetricOption) + ?.formula + ) || yAxisSourceField === RECORDS_PERCENTAGE_FIELD; + + let orderBy: TermColumnParamsOrderBy = { + type: 'column', + columnId: `y-axis-column-${layerId}-0`, + }; + + if (isFormulaColumn) { + orderBy = { type: 'custom' }; + } else if (alphabeticOrder) { + orderBy = { type: 'alphabetical', fallback: true }; + } + + return { + sourceField, + label: labels[sourceField], + dataType: fieldMeta?.type as DataType, + operationType: 'terms', + scale: 'ordinal', + isBucketed: true, + params: { + orderBy, + size, + orderDirection: 'desc', + otherBucket: true, + missingBucket: false, + ...(isFormulaColumn + ? { + orderAgg: { + label: 'Count of records', + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: '___records___', + }, + } + : {}), + }, + }; + } + + getNumberRangeColumn( + sourceField: string, + seriesConfig: SeriesConfig, + label?: string + ): RangeIndexPatternColumn { + return { + sourceField, + label: seriesConfig.labels[sourceField] ?? label, + dataType: 'number', + operationType: 'range', + isBucketed: true, + scale: 'interval', + params: { + type: 'histogram', + ranges: [{ from: 0, to: 1000, label: '' }], + maxBars: 'auto', + }, + }; + } + + getFiltersColumn({ + label, + paramFilters, + }: { + paramFilters: ParamFilter[]; + label?: string; + }): FiltersIndexPatternColumn { + return { + label: label ?? 'Filters', + dataType: 'string', + operationType: 'filters', + scale: 'ordinal', + isBucketed: true, + params: { + filters: paramFilters, + }, + }; + } + + getNumberColumn({ + seriesConfig, + label, + sourceField, + columnType, + columnFilter, + operationType, + }: { + sourceField: string; + columnType?: string; + columnFilter?: ColumnFilter; + operationType?: SupportedOperations | 'last_value'; + label?: string; + seriesConfig: SeriesConfig; + }) { + if (columnType === 'operation' || operationType) { + if ( + operationType && + ['median', 'average', 'sum', 'min', 'max', 'unique_count'].includes(operationType) + ) { + return this.getNumberOperationColumn({ + sourceField, + operationType: operationType as SupportedOperations, + label, + seriesConfig, + columnFilter, + }); + } + if (operationType === 'last_value') { + return this.getLastValueOperationColumn({ + sourceField, + operationType, + label, + seriesConfig, + columnFilter, + }); + } + if (operationType?.includes('th')) { + return this.getPercentileNumberColumn(sourceField, operationType, seriesConfig!, label); + } + } + return this.getNumberRangeColumn(sourceField, seriesConfig!, label); + } + + getLastValueOperationColumn({ + sourceField, + label, + seriesConfig, + operationType, + columnFilter, + }: { + sourceField: string; + operationType: 'last_value'; + label?: string; + seriesConfig: SeriesConfig; + columnFilter?: ColumnFilter; + }): LastValueIndexPatternColumn { + return { + ...buildNumberColumn(sourceField), + operationType, + label: label || seriesConfig.labels[sourceField], + filter: columnFilter, + params: { + sortField: '@timestamp', + showArrayValues: false, + }, + }; + } + + getNumberOperationColumn({ + sourceField, + label, + seriesConfig, + operationType, + columnFilter, + }: { + sourceField: string; + operationType: SupportedOperations; + label?: string; + seriesConfig: SeriesConfig; + columnFilter?: ColumnFilter; + }): + | MinIndexPatternColumn + | MaxIndexPatternColumn + | AvgIndexPatternColumn + | MedianIndexPatternColumn + | SumIndexPatternColumn + | CardinalityIndexPatternColumn { + return { + ...buildNumberColumn(sourceField), + label: + label ?? + i18n.translate('xpack.exploratoryView.expView.columns.operation.label', { + defaultMessage: '{operationType} of {sourceField}', + values: { + sourceField: seriesConfig.labels[sourceField], + operationType: capitalize(operationType), + }, + }), + filter: columnFilter, + operationType, + params: + operationType === 'unique_count' + ? { + emptyAsNull: true, + } + : {}, + }; + } + + getPercentileBreakdowns( + layerConfig: LayerConfig, + layerId: string, + columnFilter?: string + ): Record { + const yAxisColumns = layerConfig.seriesConfig.yAxisColumns; + const { sourceField: mainSourceField, label: mainLabel } = yAxisColumns[0]; + const lensColumns: Record = + {}; + + // start at 1, because main y axis will have the first percentile breakdown + for (let i = 1; i < PERCENTILE_RANKS.length; i++) { + lensColumns[`y-axis-column-${i}`] = { + ...this.getColumnBasedOnType({ + sourceField: mainSourceField!, + operationType: PERCENTILE_RANKS[i] as SupportedOperations, + label: mainLabel, + layerConfig, + layerId, + colIndex: i, + }), + filter: { query: columnFilter || '', language: 'kuery' }, + }; + } + return lensColumns; + } + + getPercentileNumberColumn( + sourceField: string, + percentileValue: string, + seriesConfig: SeriesConfig, + label?: string + ): PercentileIndexPatternColumn { + return { + ...buildNumberColumn(sourceField), + label: + label ?? + i18n.translate('xpack.exploratoryView.expView.columns.label', { + defaultMessage: '{percentileValue} percentile of {sourceField}', + values: { sourceField: seriesConfig.labels[sourceField]?.toLowerCase(), percentileValue }, + }), + operationType: 'percentile', + params: getPercentileParam(percentileValue), + customLabel: true, + }; + } + + getDateHistogramColumn(sourceField: string): DateHistogramIndexPatternColumn { + return { + sourceField, + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { interval: 'auto', includeEmptyRows: true }, + scale: 'interval', + }; + } + + getTermsColumn(sourceField: string, label?: string): TermsIndexPatternColumn { + return { + operationType: 'terms', + sourceField, + label: 'Top values of ' + label || sourceField, + dataType: 'string', + isBucketed: true, + scale: 'ordinal', + params: { + size: 10, + orderBy: { + type: 'alphabetical', + fallback: false, + }, + orderDirection: 'desc', + }, + }; + } + + getXAxis(layerConfig: LayerConfig, layerId: string) { + const { xAxisColumn } = layerConfig.seriesConfig; + + let xSourceFiled = xAxisColumn?.sourceField; + + if (!xSourceFiled) { + return [xAxisColumn as LastValueIndexPatternColumn]; + } + + if (xSourceFiled === USE_BREAK_DOWN_COLUMN) { + return this.getBreakdownColumn({ + layerId, + layerConfig, + sourceField: layerConfig.breakdown || layerConfig.seriesConfig.breakdownFields[0], + }); + } + + if (xSourceFiled === REPORT_METRIC_FIELD) { + const { paramFilters } = this.getFieldMeta(xSourceFiled, layerConfig); + if (paramFilters) { + return this.getFiltersColumn({ paramFilters }); + } + } + + if (xSourceFiled === REPORT_METRIC_TIMESTAMP) { + const metricOption = parseCustomFieldName( + layerConfig.seriesConfig, + layerConfig.selectedMetricField + ); + + if (!Array.isArray(metricOption)) { + const { timestampField = '@timestamp' } = metricOption; + xSourceFiled = timestampField; + } + } + + return this.getColumnBasedOnType({ + layerConfig, + layerId, + label: xAxisColumn.label, + sourceField: xSourceFiled, + }); + } + + getColumnBasedOnType({ + sourceField, + label, + layerConfig, + operationType, + colIndex, + layerId, + metricOption, + }: { + sourceField: string; + metricOption?: MetricOption; + operationType?: SupportedOperations; + label?: string; + layerId: string; + layerConfig: LayerConfig; + colIndex?: number; + }) { + const { breakdown, seriesConfig } = layerConfig; + const fieldMetaInfo = this.getFieldMeta(sourceField, layerConfig, metricOption); + + const { + format, + formula, + fieldMeta, + columnType, + fieldName, + columnLabel, + timeScale, + columnFilters, + showPercentileAnnotations, + } = fieldMetaInfo; + + if (columnType === FORMULA_COLUMN) { + return getDistributionInPercentageColumn({ + layerId, + formula, + format, + label: columnLabel ?? label, + dataView: layerConfig.dataView, + lensFormulaHelper: this.lensFormulaHelper!, + }).main; + } + + if (showPercentileAnnotations) { + this.addThresholdLayer(fieldName, layerId, layerConfig); + } + + const { type: fieldType } = fieldMeta ?? {}; + + if (columnType === TERMS_COLUMN) { + return this.getTermsColumn(fieldName, label || columnLabel); + } + + if (fieldName === RECORDS_FIELD || columnType === FILTER_RECORDS) { + return this.getRecordsColumn( + label || columnLabel, + colIndex !== undefined ? columnFilters?.[colIndex] : undefined, + timeScale + ); + } + + if (fieldType === 'date') { + return this.getDateHistogramColumn(fieldName); + } + + if (fieldType === 'number' && breakdown === PERCENTILE) { + return { + ...this.getPercentileNumberColumn( + fieldName, + operationType || PERCENTILE_RANKS[0], + seriesConfig!, + label || columnLabel + ), + filter: colIndex !== undefined ? columnFilters?.[colIndex] : undefined, + }; + } + + if (fieldType === 'number') { + return this.getNumberColumn({ + sourceField: fieldName, + columnType, + columnFilter: columnFilters?.[0], + operationType, + label: label || columnLabel, + seriesConfig: layerConfig.seriesConfig, + }); + } + if (operationType === 'unique_count' || fieldType === 'string') { + return this.getNumberOperationColumn({ + sourceField: fieldName, + operationType: 'unique_count', + label: label || columnLabel, + seriesConfig: layerConfig.seriesConfig, + columnFilter: columnFilters?.[0], + }); + } + + // FIXME review my approach again + return this.getDateHistogramColumn(fieldName); + } + + getCustomFieldName({ + sourceField, + layerConfig, + }: { + sourceField: string; + layerConfig: LayerConfig; + }) { + return parseCustomFieldName(layerConfig.seriesConfig, sourceField); + } + + getFieldMeta(sourceField: string, layerConfig: LayerConfig, metricOpt?: MetricOption) { + if (sourceField === REPORT_METRIC_FIELD) { + const metricOption = metricOpt + ? { + ...metricOpt, + columnLabel: metricOpt.label, + columnField: metricOpt.field, + fieldName: metricOpt.field!, + } + : parseCustomFieldName(layerConfig.seriesConfig, layerConfig.selectedMetricField); + + if (Array.isArray(metricOption)) { + return { + fieldName: sourceField, + items: metricOption, + }; + } + + const { + palette, + fieldName, + columnType, + columnLabel, + columnFilters, + timeScale, + paramFilters, + showPercentileAnnotations, + formula, + format, + } = metricOption; + const fieldMeta = layerConfig.dataView.getFieldByName(fieldName!); + return { + format, + formula, + palette, + fieldMeta, + fieldName, + columnType, + columnLabel, + columnFilters, + timeScale, + paramFilters, + showPercentileAnnotations: + layerConfig.showPercentileAnnotations ?? showPercentileAnnotations, + }; + } else { + const fieldMeta = layerConfig.dataView.getFieldByName(sourceField); + + return { fieldMeta, fieldName: sourceField }; + } + } + + getMainYAxis(layerConfig: LayerConfig, layerId: string, columnFilter: string) { + const { breakdown } = layerConfig; + const { + sourceField, + operationType, + label: colLabel, + timeScale, + } = layerConfig.seriesConfig.yAxisColumns[0]; + + let label = layerConfig.name || colLabel; + + if (layerConfig.seriesConfig.reportType === ReportTypes.CORE_WEB_VITAL) { + label = colLabel; + } + + if (sourceField === RECORDS_PERCENTAGE_FIELD) { + return [ + getDistributionInPercentageColumn({ + label, + layerId, + columnFilter, + dataView: layerConfig.dataView, + lensFormulaHelper: this.lensFormulaHelper!, + }).main, + ]; + } + + if (sourceField === RECORDS_FIELD || !sourceField) { + return [this.getRecordsColumn(label, undefined, timeScale)]; + } + + const fieldMetaInfo = this.getFieldMeta(sourceField, layerConfig); + + if (!fieldMetaInfo.fieldMeta && fieldMetaInfo.fieldName === RECORDS_FIELD) { + return [this.getRecordsColumn(label, fieldMetaInfo.columnFilters?.[0], timeScale)]; + } + + if ('items' in fieldMetaInfo) { + const { items } = fieldMetaInfo; + + return items?.map((item, index) => { + return this.getColumnBasedOnType({ + layerConfig, + layerId, + label: item.label, + sourceField: REPORT_METRIC_FIELD, + metricOption: item, + operationType: operationType as SupportedOperations, + }); + }); + } + + return [ + this.getColumnBasedOnType({ + sourceField, + label, + layerConfig, + colIndex: 0, + operationType: (breakdown === PERCENTILE + ? PERCENTILE_RANKS[0] + : operationType) as SupportedOperations, + layerId, + }), + ]; + } + + getChildYAxises( + layerConfig: LayerConfig, + layerId: string, + columnFilter?: string, + forAccessorsKeys?: boolean + ) { + const { breakdown } = layerConfig; + const lensColumns: Record< + string, + FieldBasedIndexPatternColumn | SumIndexPatternColumn | FormulaIndexPatternColumn + > = {}; + const yAxisColumns = layerConfig.seriesConfig.yAxisColumns; + const { sourceField: mainSourceField, label: mainLabel } = yAxisColumns[0]; + + if (mainSourceField === RECORDS_PERCENTAGE_FIELD && layerId && !forAccessorsKeys) { + return getDistributionInPercentageColumn({ + label: mainLabel, + layerId, + columnFilter, + dataView: layerConfig.dataView, + lensFormulaHelper: this.lensFormulaHelper!, + }).supportingColumns; + } + + if (mainSourceField && !forAccessorsKeys) { + const { columnLabel, formula, columnType } = this.getFieldMeta(mainSourceField, layerConfig); + + if (columnType === FORMULA_COLUMN) { + return getDistributionInPercentageColumn({ + label: columnLabel, + layerId, + formula, + dataView: layerConfig.dataView, + lensFormulaHelper: this.lensFormulaHelper!, + }).supportingColumns; + } + } + + if (yAxisColumns.length === 1 && breakdown === PERCENTILE) { + return this.getPercentileBreakdowns(layerConfig, layerId, columnFilter); + } + + if (yAxisColumns.length === 1) { + return lensColumns; + } + + // starting from 1 index since 0 column is used as main column + for (let i = 1; i < yAxisColumns.length; i++) { + const { sourceField, operationType, label } = yAxisColumns[i]; + + lensColumns[`y-axis-column-${i}`] = this.getColumnBasedOnType({ + sourceField: sourceField!, + operationType: operationType as SupportedOperations, + label, + layerConfig, + colIndex: i, + layerId, + }); + } + return lensColumns; + } + + getRecordsColumn( + label?: string, + columnFilter?: ColumnFilter, + timeScale?: string + ): CountIndexPatternColumn { + return { + dataType: 'number', + isBucketed: false, + label: label || 'Count of records', + operationType: 'count', + scale: 'ratio', + sourceField: RECORDS_FIELD, + filter: columnFilter, + ...(timeScale ? { timeScale } : {}), + } as CountIndexPatternColumn; + } + + getLayerFilters(layerConfig: LayerConfig, totalLayers: number) { + const { + filters, + time, + seriesConfig: { baseFilters: layerFilters, reportType }, + } = layerConfig; + let baseFilters = ''; + + if (reportType !== ReportTypes.KPI && totalLayers > 1 && time) { + // for kpi over time, we don't need to add time range filters + // since those are essentially plotted along the x-axis + baseFilters += `@timestamp >= ${time.from} and @timestamp <= ${time.to}`; + } + + layerFilters?.forEach((filter: PersistableFilter | ExistsFilter) => { + const qFilter = filter as PersistableFilter; + if (qFilter.query?.match_phrase) { + const fieldName = Object.keys(qFilter.query.match_phrase)[0]; + const kql = `${fieldName}: ${qFilter.query.match_phrase[fieldName]}`; + if (baseFilters.length > 0) { + baseFilters += ` and ${kql}`; + } else { + baseFilters += kql; + } + } + if (qFilter.query?.bool?.should) { + const values: string[] = []; + let fieldName = ''; + qFilter.query?.bool.should.forEach((ft: any) => { + if (ft.match_phrase) { + fieldName = Object.keys(ft.match_phrase)[0]; + values.push(ft.match_phrase[fieldName]); + } + }); + + const kueryString = `${fieldName}: (${values.join(' or ')})`; + + if (baseFilters.length > 0) { + baseFilters += ` and ${kueryString}`; + } else { + baseFilters += kueryString; + } + } + const existFilter = filter as ExistsFilter; + + if (isExistsFilter(existFilter)) { + const fieldName = existFilter.query.exists?.field; + const kql = `${fieldName} : *`; + if (baseFilters.length > 0) { + baseFilters += ` and ${kql}`; + } else { + baseFilters += kql; + } + } + }); + + const urlFilters = urlFiltersToKueryString(filters ?? []); + + if (!baseFilters) { + return urlFilters; + } + if (!urlFilters) { + return baseFilters; + } + return `${urlFilters} and ${baseFilters}`; + } + + getTimeShift(mainLayerConfig: LayerConfig, layerConfig: LayerConfig, index: number) { + if ( + index === 0 || + mainLayerConfig.seriesConfig.reportType !== ReportTypes.KPI || + !layerConfig.time + ) { + return null; + } + + const { + time: { from: mainFrom }, + } = mainLayerConfig; + + const { + time: { from }, + } = layerConfig; + + const parsedMainFrom = parseRelativeDate(mainFrom); + const parsedFrom = parseRelativeDate(from); + + const inDays = + parsedMainFrom && parsedFrom ? Math.abs(parsedMainFrom.diff(parsedFrom, 'days')) : 0; + if (inDays > 1) { + return inDays + 'd'; + } + + const inHours = + parsedMainFrom && parsedFrom ? Math.abs(parsedMainFrom?.diff(parsedFrom, 'hours')) : 0; + if (inHours === 0) { + return null; + } + return inHours + 'h'; + } + + getLayers() { + const layers: Record = {}; + const layerConfigs = this.layerConfigs; + + layerConfigs.forEach((layerConfig, index) => { + const { breakdown, seriesConfig } = layerConfig; + + const layerId = `layer${index}`; + const columnFilter = this.getLayerFilters(layerConfig, layerConfigs.length); + const timeShift = this.getTimeShift(this.layerConfigs[0], layerConfig, index); + const mainYAxises = this.getMainYAxis(layerConfig, layerId, columnFilter); + const { sourceField } = seriesConfig.xAxisColumn; + + const hasBreakdownColumn = + // do nothing since this will be used a x axis source + Boolean(breakdown && sourceField !== USE_BREAK_DOWN_COLUMN && breakdown !== PERCENTILE); + + layers[layerId] = this.getDataLayer({ + layerId, + layerConfig, + mainYAxises, + columnFilter, + timeShift, + hasBreakdownColumn, + }); + }); + + Object.entries(this.seriesReferenceLines).forEach(([id, { layerData }]) => { + layers[id] = layerData; + }); + + return layers; + } + + getDataLayer({ + hasBreakdownColumn, + layerId, + layerConfig, + columnFilter, + mainYAxises, + timeShift, + }: { + hasBreakdownColumn: boolean; + layerId: string; + timeShift: string | null; + layerConfig: LayerConfig; + columnFilter: string; + mainYAxises: MainYAxisColType; + }) { + const allYAxisColumns: Record = {}; + + mainYAxises?.forEach((mainYAxis, index) => { + let filterQuery = columnFilter || mainYAxis.filter?.query; + + if (columnFilter && mainYAxis.filter?.query) { + filterQuery = `${columnFilter} and ${mainYAxis.filter.query}`; + } + + const label = timeShift ? `${mainYAxis.label}(${timeShift})` : mainYAxis.label; + + allYAxisColumns[`y-axis-column-${layerId}-${index}`] = { + ...mainYAxis, + label, + filter: { + query: filterQuery ?? '', + language: 'kuery', + }, + ...(timeShift ? { timeShift } : {}), + }; + }); + + const { breakdown } = layerConfig; + + const breakDownColumn = hasBreakdownColumn + ? this.getBreakdownColumn({ + layerId, + sourceField: breakdown!, + layerConfig, + }) + : null; + + const xAxises = { + [`x-axis-column-${layerId}`]: this.getXAxis(layerConfig, layerId), + }; + + return { + columnOrder: [ + ...(hasBreakdownColumn ? [`breakdown-column-${layerId}`] : []), + ...Object.keys(xAxises), + ...Object.keys(allYAxisColumns), + ...Object.keys(this.getChildYAxises(layerConfig, layerId, columnFilter)), + ], + columns: { + ...xAxises, + ...allYAxisColumns, + ...(hasBreakdownColumn + ? { + [`breakdown-column-${layerId}`]: breakDownColumn!, + } + : {}), + ...this.getChildYAxises(layerConfig, layerId, columnFilter), + }, + incompleteColumns: {}, + }; + } + + getXyState(): XYState { + return { + legend: { + isVisible: true, + showSingleSeries: true, + position: 'right', + legendSize: LegendSize.AUTO, + shouldTruncate: false, + }, + valueLabels: 'hide', + fittingFunction: 'Linear', + curveType: 'CURVE_MONOTONE_X' as XYCurveType, + axisTitlesVisibilitySettings: { + x: false, + yLeft: !this.isMultiSeries, + yRight: !this.isMultiSeries, + }, + tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, + gridlinesVisibilitySettings: { x: false, yLeft: true, yRight: true }, + preferredSeriesType: 'line', + layers: this.getDataLayers(), + ...(this.layerConfigs[0].seriesConfig.yTitle + ? { yTitle: this.layerConfigs[0].seriesConfig.yTitle } + : {}), + }; + } + + getDataLayers(): XYState['layers'] { + const dataLayers = this.layerConfigs.map((layerConfig, index) => { + const { sourceField } = layerConfig.seriesConfig.yAxisColumns[0]; + + let palette = layerConfig.seriesConfig.palette; + + if (sourceField) { + const fieldMeta = this.getFieldMeta(sourceField, layerConfig); + if (fieldMeta.palette) { + palette = fieldMeta.palette; + } + } + + const layerId = `layer${index}`; + + const columnFilter = this.getLayerFilters(layerConfig, this.layerConfigs.length); + + const mainYAxises = this.getMainYAxis(layerConfig, layerId, columnFilter) ?? []; + + return { + accessors: [ + ...mainYAxises.map((key, yIndex) => `y-axis-column-${layerId}-${yIndex}`), + ...Object.keys(this.getChildYAxises(layerConfig, `layer${index}`, undefined, true)), + ], + layerId: `layer${index}`, + layerType: 'data' as any, + seriesType: layerConfig.seriesType || layerConfig.seriesConfig.defaultSeriesType, + palette: palette ?? layerConfig.seriesConfig.palette, + yConfig: layerConfig.seriesConfig.yConfig || [ + { + forAccessor: `y-axis-column-layer${index}-0`, + color: layerConfig.color, + /* if the fields format matches the field format of the first layer, use the default y axis (right) + * if not, use the secondary y axis (left) */ + axisMode: + layerConfig.dataView.fieldFormatMap[layerConfig.selectedMetricField]?.id === + this.layerConfigs[0].dataView.fieldFormatMap[this.layerConfigs[0].selectedMetricField] + ?.id + ? ('left' as YAxisMode) + : ('right' as YAxisMode), + }, + ], + xAccessor: `x-axis-column-layer${index}`, + ...(layerConfig.breakdown && + layerConfig.breakdown !== PERCENTILE && + layerConfig.seriesConfig.xAxisColumn.sourceField !== USE_BREAK_DOWN_COLUMN + ? { splitAccessor: `breakdown-column-layer${index}` } + : {}), + ...(this.layerConfigs[0].seriesConfig.yTitle + ? { yTitle: this.layerConfigs[0].seriesConfig.yTitle } + : {}), + }; + }); + + const referenceLineLayers: XYState['layers'] = []; + + Object.entries(this.seriesReferenceLines).forEach(([_id, { layerState }]) => { + referenceLineLayers.push(layerState[0]); + }); + + return [...dataLayers, ...referenceLineLayers]; + } + + addThresholdLayer(fieldName: string, layerId: string, { seriesConfig, dataView }: LayerConfig) { + const referenceLineLayerId = `${layerId}-reference-lines`; + + const referenceLineColumns = this.getThresholdColumns( + fieldName, + referenceLineLayerId, + seriesConfig + ); + + const layerData = { + columnOrder: Object.keys(referenceLineColumns), + columns: referenceLineColumns, + incompleteColumns: {}, + }; + + const layerState = this.getThresholdLayer(fieldName, referenceLineLayerId, seriesConfig); + + this.seriesReferenceLines[referenceLineLayerId] = { layerData, layerState, dataView }; + } + + getThresholdLayer( + fieldName: string, + referenceLineLayerId: string, + seriesConfig: SeriesConfig + ): XYState['layers'] { + const columns = this.getThresholdColumns(fieldName, referenceLineLayerId, seriesConfig); + + return [ + { + layerId: referenceLineLayerId, + accessors: Object.keys(columns), + layerType: 'referenceLine', + yConfig: Object.keys(columns).map((columnId) => ({ + axisMode: 'bottom', + color: '#6092C0', + forAccessor: columnId, + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + })), + }, + ]; + } + + getThresholdColumns(fieldName: string, layerId: string, seriesConfig: SeriesConfig) { + const referenceLines = ['50th', '75th', '90th', '95th', '99th']; + const columns: Record = {}; + + referenceLines.forEach((referenceLine) => { + columns[`${referenceLine}-percentile-reference-line-${layerId}`] = { + ...this.getPercentileNumberColumn(fieldName, referenceLine, seriesConfig), + label: referenceLine, + }; + }); + + return columns; + } + + getReferences() { + const uniqueIndexPatternsIds = Array.from( + new Set([...this.layerConfigs.map(({ dataView }) => dataView.id!)]) + ); + + const adHocDataViews: Record = {}; + + const referenceLineIndexReferences = Object.entries(this.seriesReferenceLines).map( + ([id, { dataView }]) => { + adHocDataViews[dataView.id!] = dataView.toSpec(false); + return { + id: dataView.id!, + name: getLayerReferenceName(id), + type: 'index-pattern', + }; + } + ); + + const internalReferences = [ + ...uniqueIndexPatternsIds.map((dataViewId) => ({ + id: dataViewId, + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + })), + ...this.layerConfigs.map(({ dataView }, index) => { + adHocDataViews[dataView.id!] = dataView.toSpec(false); + + return { + id: dataView.id!, + name: getLayerReferenceName(`layer${index}`), + type: 'index-pattern', + }; + }), + ...referenceLineIndexReferences, + ]; + + Object.entries(this.seriesReferenceLines).map(([id, { dataView }]) => ({ + id: dataView.id!, + name: getLayerReferenceName(id), + type: 'index-pattern', + })); + + return { internalReferences, adHocDataViews }; + } + + getJSON( + visualizationType: 'lnsXY' | 'lnsLegacyMetric' | 'lnsHeatmap' = 'lnsXY', + lastRefresh?: number + ): TypedLensByValueInput['attributes'] { + const query = this.globalFilter || this.layerConfigs[0].seriesConfig.query; + + const { internalReferences, adHocDataViews } = this.getReferences(); + + return { + title: 'Prefilled from exploratory view app', + description: lastRefresh ? `Last refreshed at ${new Date(lastRefresh).toISOString()}` : '', + visualizationType, + references: [], + state: { + internalReferences, + adHocDataViews, + datasourceStates: { + formBased: { + layers: this.layers, + }, + }, + visualization: this.visualization, + query: query || { query: '', language: 'kuery' }, + filters: [], + }, + }; + } +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes/heatmap_attributes.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes/heatmap_attributes.ts new file mode 100644 index 0000000000000..d62bd8684b48c --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes/heatmap_attributes.ts @@ -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 { FormulaPublicApi, HeatmapVisualizationState } from '@kbn/lens-plugin/public'; + +import { euiPaletteNegative } from '@elastic/eui'; +import { ColorStop } from '@kbn/coloring'; +import { LayerConfig } from '../lens_attributes'; +import { SingleMetricLensAttributes } from './single_metric_attributes'; + +export class HeatMapLensAttributes extends SingleMetricLensAttributes { + xColumnId: string; + layerId: string; + breakDownColumnId: string; + + constructor( + layerConfigs: LayerConfig[], + reportType: string, + lensFormulaHelper: FormulaPublicApi + ) { + super(layerConfigs, reportType, lensFormulaHelper); + + this.xColumnId = 'layer-0-column-x-1'; + this.breakDownColumnId = 'layer-0-breakdown-column'; + this.layerId = 'layer0'; + const layer0 = this.getSingleMetricLayer()!; + + layer0.columns[this.xColumnId] = this.getDateHistogramColumn('@timestamp'); + + let columnOrder = [this.xColumnId]; + const layerConfig = layerConfigs[0]; + + if (layerConfig.breakdown) { + columnOrder = [this.breakDownColumnId, ...columnOrder]; + layer0.columns[this.breakDownColumnId] = this.getBreakdownColumn({ + layerConfig, + sourceField: layerConfig.breakdown, + layerId: this.layerId, + alphabeticOrder: true, + }); + } + + layer0.columnOrder = [...columnOrder, ...layer0.columnOrder]; + + this.layers = { layer0 }; + + this.visualization = this.getHeatmapState(); + } + + getHeatmapState() { + const negativePalette = euiPaletteNegative(5); + const layerConfig = this.layerConfigs[0]; + + return { + shape: 'heatmap', + layerId: this.layerId, + layerType: 'data', + legend: { + isVisible: true, + position: 'right', + type: 'heatmap_legend', + }, + gridConfig: { + type: 'heatmap_grid', + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + isYAxisTitleVisible: false, + isXAxisTitleVisible: false, + xTitle: '', + }, + valueAccessor: this.columnId, + xAccessor: this.xColumnId, + yAccessor: layerConfig.breakdown ? this.breakDownColumnId : undefined, + palette: { + type: 'palette', + name: 'negative', + params: { + name: 'negative', + continuity: 'above', + reverse: false, + stops: negativePalette.map((nColor, ind) => ({ + color: nColor, + stop: ind === 0 ? 1 : ind * 20, + })) as ColorStop[], + rangeMin: 0, + }, + accessor: this.columnId, + }, + } as HeatmapVisualizationState; + } +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes/single_metric_attributes.test.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes/single_metric_attributes.test.ts new file mode 100644 index 0000000000000..16f35bab1f1c5 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes/single_metric_attributes.test.ts @@ -0,0 +1,213 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SingleMetricLensAttributes } from './single_metric_attributes'; +import { ReportTypes } from '../../../../..'; +import { mockAppDataView, mockDataView } from '../../rtl_helpers'; +import { getDefaultConfigs } from '../default_configs'; +import { obsvReportConfigMap } from '../../obsv_exploratory_view'; +import { buildExistsFilter } from '../utils'; +import { LensAttributes } from '../lens_attributes'; +import { TRANSACTION_DURATION } from '../constants/elasticsearch_fieldnames'; +import { lensPluginMock } from '@kbn/lens-plugin/public/mocks'; +import { FormulaPublicApi } from '@kbn/lens-plugin/public'; +import { sampleMetricFormulaAttribute } from '../test_data/test_formula_metric_attribute'; +import { DataTypes } from '../..'; + +describe('SingleMetricAttributes', () => { + mockAppDataView(); + + const reportViewConfig = getDefaultConfigs({ + reportType: ReportTypes.SINGLE_METRIC, + dataType: 'ux', + dataView: mockDataView, + reportConfigMap: obsvReportConfigMap, + }); + + reportViewConfig.baseFilters?.push(...buildExistsFilter('transaction.type', mockDataView)); + + let lnsAttr: LensAttributes; + + const layerConfig: any = { + seriesConfig: reportViewConfig, + operationType: 'median', + dataView: mockDataView, + reportDefinitions: {}, + time: { from: 'now-15m', to: 'now' }, + name: 'Page load time', + selectedMetricField: TRANSACTION_DURATION, + }; + + const lensPluginMockStart = lensPluginMock.createStartContract(); + + let formulaHelper: FormulaPublicApi; + + beforeEach(async () => { + formulaHelper = (await lensPluginMockStart.stateHelperApi()).formula; + lnsAttr = new SingleMetricLensAttributes( + [layerConfig], + ReportTypes.SINGLE_METRIC, + formulaHelper + ); + }); + + it('returns attributes as expected', () => { + const jsonAttr = lnsAttr.getJSON('lnsLegacyMetric'); + expect(jsonAttr).toEqual({ + description: '', + references: [], + state: { + adHocDataViews: { [mockDataView.title]: mockDataView.toSpec(false) }, + internalReferences: [ + { + id: 'apm-*', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'apm-*', + name: 'indexpattern-datasource-layer-layer0', + type: 'index-pattern', + }, + ], + datasourceStates: { + formBased: { + layers: { + layer0: { + columnOrder: ['layer-0-column-1'], + columns: { + 'layer-0-column-1': { + dataType: 'number', + isBucketed: false, + label: 'Page load time', + operationType: 'median', + scale: 'ratio', + sourceField: 'transaction.duration.us', + params: { + emptyAsNull: true, + }, + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { + language: 'kuery', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.type : *', + }, + visualization: { + accessor: 'layer-0-column-1', + layerId: 'layer0', + layerType: 'data', + size: 's', + }, + }, + title: 'Prefilled from exploratory view app', + visualizationType: 'lnsLegacyMetric', + }); + }); + + it('returns attributes as expected for percentile operation', () => { + layerConfig.operationType = '99th'; + lnsAttr = new SingleMetricLensAttributes( + [layerConfig], + ReportTypes.SINGLE_METRIC, + formulaHelper + ); + + const jsonAttr = lnsAttr.getJSON('lnsLegacyMetric'); + expect(jsonAttr).toEqual({ + description: '', + references: [], + state: { + adHocDataViews: { [mockDataView.title]: mockDataView.toSpec(false) }, + internalReferences: [ + { + id: 'apm-*', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'apm-*', + name: 'indexpattern-datasource-layer-layer0', + type: 'index-pattern', + }, + ], + datasourceStates: { + formBased: { + layers: { + layer0: { + columnOrder: ['layer-0-column-1'], + columns: { + 'layer-0-column-1': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: 'Page load time', + operationType: 'percentile', + params: { + percentile: 99, + }, + scale: 'ratio', + sourceField: 'transaction.duration.us', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { + language: 'kuery', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.type : *', + }, + visualization: { + accessor: 'layer-0-column-1', + layerId: 'layer0', + layerType: 'data', + size: 's', + }, + }, + title: 'Prefilled from exploratory view app', + visualizationType: 'lnsLegacyMetric', + }); + }); + + it('returns attributes as expected for formula column', () => { + const reportViewConfigFormula = getDefaultConfigs({ + reportType: ReportTypes.SINGLE_METRIC, + dataType: DataTypes.SYNTHETICS, + dataView: mockDataView, + reportConfigMap: obsvReportConfigMap, + }); + + const layerConfigFormula: any = { + seriesConfig: reportViewConfigFormula, + operationType: 'median', + dataView: mockDataView, + reportDefinitions: {}, + time: { from: 'now-15m', to: 'now' }, + name: 'Availability', + selectedMetricField: 'monitor_availability', + }; + + lnsAttr = new SingleMetricLensAttributes( + [layerConfigFormula], + ReportTypes.SINGLE_METRIC, + formulaHelper + ); + + const jsonAttr = lnsAttr.getJSON('lnsLegacyMetric'); + expect(jsonAttr).toEqual(sampleMetricFormulaAttribute); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes/single_metric_attributes.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes/single_metric_attributes.ts new file mode 100644 index 0000000000000..79956df5f72d6 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_attributes/single_metric_attributes.ts @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FormulaPublicApi, MetricState, OperationType } from '@kbn/lens-plugin/public'; + +import type { DataView } from '@kbn/data-views-plugin/common'; + +import { Query } from '@kbn/es-query'; +import { getColorPalette } from '../synthetics/single_metric_config'; +import { FORMULA_COLUMN, RECORDS_FIELD } from '../constants'; +import { ColumnFilter, MetricOption } from '../../types'; +import { SeriesConfig } from '../../../../..'; +import { + buildNumberColumn, + LayerConfig, + LensAttributes, + parseCustomFieldName, +} from '../lens_attributes'; + +export class SingleMetricLensAttributes extends LensAttributes { + columnId: string; + metricStateOptions?: MetricOption['metricStateOptions']; + + constructor( + layerConfigs: LayerConfig[], + reportType: string, + lensFormulaHelper: FormulaPublicApi + ) { + super(layerConfigs, reportType, lensFormulaHelper); + this.layers = {}; + this.reportType = reportType; + + this.layerConfigs = layerConfigs; + this.isMultiSeries = layerConfigs.length > 1; + + this.columnId = 'layer-0-column-1'; + + this.globalFilter = this.getGlobalFilter(this.isMultiSeries); + const layer0 = this.getSingleMetricLayer()!; + + this.layers = { + layer0, + }; + this.visualization = this.getMetricState(); + } + + getSingleMetricLayer() { + const { seriesConfig, selectedMetricField, operationType, dataView, name } = + this.layerConfigs[0]; + + const metricOption = parseCustomFieldName(seriesConfig, selectedMetricField); + + if (!Array.isArray(metricOption)) { + const { + columnFilter, + columnField, + columnLabel, + columnType, + formula, + metricStateOptions, + format, + emptyAsNull = true, + } = metricOption; + + this.metricStateOptions = metricStateOptions; + + if (columnType === FORMULA_COLUMN && formula) { + return this.getFormulaLayer({ + formula, + label: name ?? columnLabel, + dataView, + format, + filter: columnFilter, + }); + } + + const getSourceField = () => { + if ( + selectedMetricField.startsWith('Records') || + selectedMetricField.startsWith('records') + ) { + return 'Records'; + } + return columnField || selectedMetricField; + }; + + const sourceField = getSourceField(); + + const isPercentileColumn = operationType?.includes('th'); + + if (isPercentileColumn) { + return this.getPercentileLayer({ + sourceField, + operationType, + seriesConfig, + columnLabel, + columnFilter, + }); + } + + return { + columns: { + [this.columnId]: { + ...buildNumberColumn(sourceField), + label: name ?? columnLabel, + operationType: sourceField === RECORDS_FIELD ? 'count' : operationType || 'median', + filter: columnFilter, + params: { + emptyAsNull, + }, + }, + }, + columnOrder: [this.columnId], + incompleteColumns: {}, + }; + } + } + + getFormulaLayer({ + formula, + label, + dataView, + format, + filter, + }: { + formula: string; + label?: string; + format?: string; + filter?: Query; + dataView: DataView; + }) { + const layer = this.lensFormulaHelper?.insertOrReplaceFormulaColumn( + this.columnId, + { + formula, + label, + filter, + format: + format === 'percent' || !format + ? { + id: 'percent', + params: { + decimals: 1, + }, + } + : undefined, + }, + { columns: {}, columnOrder: [] }, + dataView + ); + + return layer!; + } + + getPercentileLayer({ + sourceField, + operationType, + seriesConfig, + columnLabel, + columnFilter, + }: { + sourceField: string; + operationType?: OperationType; + seriesConfig: SeriesConfig; + columnLabel?: string; + columnFilter?: ColumnFilter; + }) { + return { + columns: { + [this.columnId]: { + ...this.getPercentileNumberColumn(sourceField, operationType!, seriesConfig), + label: columnLabel ?? '', + filter: columnFilter, + }, + }, + columnOrder: [this.columnId], + incompleteColumns: {}, + }; + } + + getMetricState(): MetricState { + const { color } = this.layerConfigs[0]; + + const metricStateOptions: MetricOption['metricStateOptions'] = { + ...(this.metricStateOptions ?? {}), + ...(color ? { colorMode: 'Labels', palette: getColorPalette(color) } : {}), + }; + + return { + accessor: this.columnId, + layerId: 'layer0', + layerType: 'data', + ...metricStateOptions, + size: 's', + }; + } +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_columns/overall_column.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_columns/overall_column.ts new file mode 100644 index 0000000000000..eeae6964318a2 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/lens_columns/overall_column.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { FormulaIndexPatternColumn, FormulaPublicApi } from '@kbn/lens-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/common'; + +export function getDistributionInPercentageColumn({ + label, + layerId, + dataView, + columnFilter, + lensFormulaHelper, + formula, + format, +}: { + label?: string; + columnFilter?: string; + layerId: string; + lensFormulaHelper: FormulaPublicApi; + dataView: DataView; + formula?: string; + format?: string; +}) { + const yAxisColId = `y-axis-column-${layerId}`; + + let lensFormula = formula ?? 'count() / overall_sum(count())'; + + if (columnFilter) { + lensFormula = + formula ?? `count(kql='${columnFilter}') / overall_sum(count(kql='${columnFilter}'))`; + } + + const { columns } = lensFormulaHelper?.insertOrReplaceFormulaColumn( + yAxisColId, + { + formula: lensFormula, + label, + format: + format === 'percent' || !format + ? { + id: 'percent', + params: { + decimals: 0, + }, + } + : undefined, + }, + { + columns: {}, + columnOrder: [], + }, + dataView + ) ?? { columns: {} }; + + const { [yAxisColId]: main, ...supportingColumns } = columns; + + return { main: columns[yAxisColId] as FormulaIndexPatternColumn, supportingColumns }; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts new file mode 100644 index 0000000000000..ae534704976e3 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConfigProps, SeriesConfig } from '../../types'; +import { + FieldLabels, + LABEL_FIELDS_FILTER, + REPORT_METRIC_FIELD, + ReportTypes, + USE_BREAK_DOWN_COLUMN, +} from '../constants'; +import { buildPhraseFilter } from '../utils'; +import { SERVICE_NAME } from '../constants/elasticsearch_fieldnames'; +import { MOBILE_APP, NUMBER_OF_DEVICES } from '../constants/labels'; +import { MobileFields } from './mobile_fields'; + +export function getMobileDeviceDistributionConfig({ dataView }: ConfigProps): SeriesConfig { + return { + reportType: ReportTypes.DEVICE_DISTRIBUTION, + defaultSeriesType: 'bar', + seriesTypes: ['bar', 'bar_horizontal'], + xAxisColumn: { + sourceField: USE_BREAK_DOWN_COLUMN, + }, + yAxisColumns: [ + { + sourceField: REPORT_METRIC_FIELD, + operationType: 'unique_count', + }, + ], + hasOperationType: false, + filterFields: [...Object.keys(MobileFields), LABEL_FIELDS_FILTER], + breakdownFields: Object.keys(MobileFields), + baseFilters: [ + ...buildPhraseFilter('agent.name', 'iOS/swift', dataView), + ...buildPhraseFilter('processor.event', 'transaction', dataView), + ], + labels: { + ...FieldLabels, + ...MobileFields, + [SERVICE_NAME]: MOBILE_APP, + }, + definitionFields: [SERVICE_NAME], + metricOptions: [ + { + field: 'labels.device_id', + id: 'labels.device_id', + label: NUMBER_OF_DEVICES, + }, + ], + }; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts new file mode 100644 index 0000000000000..cf3fd0f3d2aab --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConfigProps, SeriesConfig } from '../../types'; +import { + FieldLabels, + LABEL_FIELDS_FILTER, + RECORDS_FIELD, + REPORT_METRIC_FIELD, + ReportTypes, +} from '../constants'; +import { buildPhrasesFilter } from '../utils'; +import { + METRIC_SYSTEM_CPU_USAGE, + METRIC_SYSTEM_MEMORY_USAGE, + PROCESSOR_EVENT, + SERVICE_ENVIRONMENT, + SERVICE_NAME, + TRANSACTION_DURATION, +} from '../constants/elasticsearch_fieldnames'; + +import { CPU_USAGE, SYSTEM_MEMORY_USAGE, MOBILE_APP, RESPONSE_LATENCY } from '../constants/labels'; +import { MobileFields } from './mobile_fields'; + +export function getMobileKPIDistributionConfig({ dataView }: ConfigProps): SeriesConfig { + return { + reportType: ReportTypes.DISTRIBUTION, + defaultSeriesType: 'bar', + seriesTypes: ['line', 'bar'], + xAxisColumn: { + sourceField: REPORT_METRIC_FIELD, + }, + yAxisColumns: [ + { + sourceField: RECORDS_FIELD, + }, + ], + hasOperationType: false, + filterFields: [...Object.keys(MobileFields), LABEL_FIELDS_FILTER], + breakdownFields: Object.keys(MobileFields), + baseFilters: [ + ...buildPhrasesFilter('agent.name', ['iOS/swift', 'open-telemetry/swift'], dataView), + ], + labels: { + ...FieldLabels, + ...MobileFields, + [SERVICE_NAME]: MOBILE_APP, + }, + definitionFields: [SERVICE_NAME, SERVICE_ENVIRONMENT], + metricOptions: [ + { + label: RESPONSE_LATENCY, + field: TRANSACTION_DURATION, + id: TRANSACTION_DURATION, + columnFilters: [ + { + language: 'kuery', + query: `${PROCESSOR_EVENT}: transaction`, + }, + ], + }, + { + label: SYSTEM_MEMORY_USAGE, + field: METRIC_SYSTEM_MEMORY_USAGE, + id: METRIC_SYSTEM_MEMORY_USAGE, + columnFilters: [ + { + language: 'kuery', + query: `${PROCESSOR_EVENT}: metric`, + }, + ], + }, + { + label: CPU_USAGE, + field: METRIC_SYSTEM_CPU_USAGE, + id: METRIC_SYSTEM_CPU_USAGE, + columnFilters: [ + { + language: 'kuery', + query: `${PROCESSOR_EVENT}: metric`, + }, + ], + }, + ], + }; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/mobile/kpi_over_time_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/mobile/kpi_over_time_config.ts new file mode 100644 index 0000000000000..4d57ca45eae64 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/mobile/kpi_over_time_config.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConfigProps, SeriesConfig } from '../../types'; +import { + FieldLabels, + LABEL_FIELDS_FILTER, + OPERATION_COLUMN, + RECORDS_FIELD, + REPORT_METRIC_FIELD, + ReportTypes, +} from '../constants'; +import { buildPhrasesFilter } from '../utils'; +import { + METRIC_SYSTEM_CPU_USAGE, + METRIC_SYSTEM_MEMORY_USAGE, + PROCESSOR_EVENT, + SERVICE_ENVIRONMENT, + SERVICE_NAME, + TRANSACTION_DURATION, +} from '../constants/elasticsearch_fieldnames'; +import { + CPU_USAGE, + SYSTEM_MEMORY_USAGE, + MOBILE_APP, + RESPONSE_LATENCY, + TRANSACTIONS_PER_MINUTE, +} from '../constants/labels'; +import { MobileFields } from './mobile_fields'; + +export function getMobileKPIConfig({ dataView }: ConfigProps): SeriesConfig { + return { + reportType: ReportTypes.KPI, + defaultSeriesType: 'line', + seriesTypes: ['line', 'bar', 'area'], + xAxisColumn: { + sourceField: '@timestamp', + }, + yAxisColumns: [ + { + sourceField: REPORT_METRIC_FIELD, + operationType: 'median', + }, + ], + hasOperationType: true, + filterFields: [...Object.keys(MobileFields), LABEL_FIELDS_FILTER], + breakdownFields: Object.keys(MobileFields), + baseFilters: [ + ...buildPhrasesFilter('agent.name', ['iOS/swift', 'open-telemetry/swift'], dataView), + ], + labels: { + ...FieldLabels, + ...MobileFields, + [TRANSACTION_DURATION]: RESPONSE_LATENCY, + [SERVICE_NAME]: MOBILE_APP, + [METRIC_SYSTEM_MEMORY_USAGE]: SYSTEM_MEMORY_USAGE, + [METRIC_SYSTEM_CPU_USAGE]: CPU_USAGE, + }, + definitionFields: [SERVICE_NAME, SERVICE_ENVIRONMENT], + metricOptions: [ + { + label: RESPONSE_LATENCY, + field: TRANSACTION_DURATION, + id: TRANSACTION_DURATION, + columnType: OPERATION_COLUMN, + }, + { + field: RECORDS_FIELD, + id: RECORDS_FIELD, + label: TRANSACTIONS_PER_MINUTE, + columnFilters: [ + { + language: 'kuery', + query: `${PROCESSOR_EVENT}: transaction`, + }, + ], + timeScale: 'm', + }, + { + label: SYSTEM_MEMORY_USAGE, + field: METRIC_SYSTEM_MEMORY_USAGE, + id: METRIC_SYSTEM_MEMORY_USAGE, + columnType: OPERATION_COLUMN, + columnFilters: [ + { + language: 'kuery', + query: `${PROCESSOR_EVENT}: metric`, + }, + ], + }, + { + label: CPU_USAGE, + field: METRIC_SYSTEM_CPU_USAGE, + id: METRIC_SYSTEM_CPU_USAGE, + columnType: OPERATION_COLUMN, + columnFilters: [ + { + language: 'kuery', + query: `${PROCESSOR_EVENT}: metric`, + }, + ], + }, + ], + }; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/mobile/mobile_fields.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/mobile/mobile_fields.ts new file mode 100644 index 0000000000000..46f9beba99e41 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/mobile/mobile_fields.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CARRIER_LOCATION, + CARRIER_NAME, + CONNECTION_TYPE, + DEVICE_MODEL, + HOST_OS, + OS_PLATFORM, + SERVICE_VERSION, + URL_LABEL, +} from '../constants/labels'; + +export const MobileFields: Record = { + 'host.os.platform': OS_PLATFORM, + 'host.os.full': HOST_OS, + 'service.version': SERVICE_VERSION, + 'network.carrier.icc': CARRIER_LOCATION, + 'network.carrier.name': CARRIER_NAME, + 'network.connection_type': CONNECTION_TYPE, + 'labels.device_model': DEVICE_MODEL, + 'url.full': URL_LABEL, +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/mobile/mobile_kpi_config.test.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/mobile/mobile_kpi_config.test.ts new file mode 100644 index 0000000000000..bd98f202b4be9 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/mobile/mobile_kpi_config.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockAppDataView, mockDataView } from '../../rtl_helpers'; +import { LensAttributes } from '../lens_attributes'; +import { METRIC_SYSTEM_MEMORY_USAGE, SERVICE_NAME } from '../constants/elasticsearch_fieldnames'; +import { obsvReportConfigMap } from '../../obsv_exploratory_view'; +import { testMobileKPIAttr } from '../test_data/mobile_test_attribute'; +import { getLayerConfigs } from '../../hooks/use_lens_attributes'; +import { DataViewState } from '../../hooks/use_app_data_view'; +import { ReportTypes } from '../../../../..'; + +describe('Mobile kpi config test', function () { + mockAppDataView(); + + let lnsAttr: LensAttributes; + + const layerConfigs = getLayerConfigs( + [ + { + time: { from: 'now-15m', to: 'now' }, + reportDefinitions: { [SERVICE_NAME]: ['ios-integration-testing'] }, + selectedMetricField: METRIC_SYSTEM_MEMORY_USAGE, + color: 'green', + name: 'test-series', + dataType: 'mobile', + }, + ], + ReportTypes.KPI, + {} as any, + { mobile: mockDataView } as DataViewState, + obsvReportConfigMap + ); + + beforeEach(() => { + lnsAttr = new LensAttributes(layerConfigs, ReportTypes.KPI); + }); + it('should return expected json', function () { + expect(lnsAttr.getJSON()).toEqual(testMobileKPIAttr); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.test.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.test.ts new file mode 100644 index 0000000000000..f7d415d05d551 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockAppDataView, mockDataView } from '../../rtl_helpers'; +import { getDefaultConfigs } from '../default_configs'; +import { LayerConfig, LensAttributes } from '../lens_attributes'; +import { sampleAttributeCoreWebVital } from '../test_data/sample_attribute_cwv'; +import { LCP_FIELD, SERVICE_NAME, USER_AGENT_OS } from '../constants/elasticsearch_fieldnames'; +import { obsvReportConfigMap } from '../../obsv_exploratory_view'; +import { ReportTypes } from '../../../../..'; + +describe('Core web vital config test', function () { + mockAppDataView(); + + const seriesConfig = getDefaultConfigs({ + reportType: ReportTypes.CORE_WEB_VITAL, + dataType: 'ux', + dataView: mockDataView, + reportConfigMap: obsvReportConfigMap, + }); + + let lnsAttr: LensAttributes; + + const layerConfig: LayerConfig = { + seriesConfig, + color: 'green', + name: 'test-series', + breakdown: USER_AGENT_OS, + dataView: mockDataView, + time: { from: 'now-15m', to: 'now' }, + reportDefinitions: { [SERVICE_NAME]: ['elastic-co'] }, + selectedMetricField: LCP_FIELD, + }; + + beforeEach(() => { + lnsAttr = new LensAttributes([layerConfig], ReportTypes.CORE_WEB_VITAL); + }); + it('should return expected json', function () { + expect(lnsAttr.getJSON()).toEqual(sampleAttributeCoreWebVital); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts new file mode 100644 index 0000000000000..0583ab390a0ef --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { euiPaletteForStatus } from '@elastic/eui'; +import { ConfigProps, SeriesConfig } from '../../types'; +import { + FieldLabels, + FILTER_RECORDS, + LABEL_FIELDS_FILTER, + REPORT_METRIC_FIELD, + ReportTypes, + USE_BREAK_DOWN_COLUMN, +} from '../constants'; +import { buildPhraseFilter } from '../utils'; +import { + CLIENT_GEO_COUNTRY_NAME, + CLS_FIELD, + FID_FIELD, + LCP_FIELD, + PROCESSOR_EVENT, + SERVICE_NAME, + TRANSACTION_TYPE, + USER_AGENT_DEVICE, + USER_AGENT_NAME, + USER_AGENT_OS, + USER_AGENT_VERSION, + TRANSACTION_URL, + USER_AGENT_OS_VERSION, + URL_FULL, + SERVICE_ENVIRONMENT, +} from '../constants/elasticsearch_fieldnames'; +import { CLS_LABEL, FID_LABEL, LCP_LABEL } from '../constants/labels'; + +export function getCoreWebVitalsConfig({ dataView }: ConfigProps): SeriesConfig { + const statusPallete = euiPaletteForStatus(3); + + return { + defaultSeriesType: 'bar_horizontal_percentage_stacked', + reportType: ReportTypes.CORE_WEB_VITAL, + seriesTypes: ['bar_horizontal_percentage_stacked'], + xAxisColumn: { + sourceField: USE_BREAK_DOWN_COLUMN, + }, + yAxisColumns: [ + { + sourceField: REPORT_METRIC_FIELD, + label: 'Good', + }, + { + sourceField: REPORT_METRIC_FIELD, + label: 'Average', + }, + { + sourceField: REPORT_METRIC_FIELD, + label: 'Poor', + }, + ], + hasOperationType: false, + filterFields: [ + { + field: TRANSACTION_URL, + isNegated: false, + }, + SERVICE_NAME, + { + field: USER_AGENT_OS, + nested: USER_AGENT_OS_VERSION, + }, + CLIENT_GEO_COUNTRY_NAME, + USER_AGENT_DEVICE, + { + field: USER_AGENT_NAME, + nested: USER_AGENT_VERSION, + }, + LABEL_FIELDS_FILTER, + ], + breakdownFields: [ + SERVICE_NAME, + USER_AGENT_NAME, + USER_AGENT_OS, + CLIENT_GEO_COUNTRY_NAME, + USER_AGENT_DEVICE, + URL_FULL, + ], + baseFilters: [ + ...buildPhraseFilter(TRANSACTION_TYPE, 'page-load', dataView), + ...buildPhraseFilter(PROCESSOR_EVENT, 'transaction', dataView), + ], + labels: { ...FieldLabels, [SERVICE_NAME]: 'Web Application' }, + definitionFields: [SERVICE_NAME, SERVICE_ENVIRONMENT], + metricOptions: [ + { + id: LCP_FIELD, + label: LCP_LABEL, + columnType: FILTER_RECORDS, + columnFilters: [ + { + language: 'kuery', + query: `${LCP_FIELD} < 2500`, + }, + { + language: 'kuery', + query: `${LCP_FIELD} > 2500 and ${LCP_FIELD} < 4000`, + }, + { + language: 'kuery', + query: `${LCP_FIELD} > 4000`, + }, + ], + }, + { + label: FID_LABEL, + id: FID_FIELD, + columnType: FILTER_RECORDS, + columnFilters: [ + { + language: 'kuery', + query: `${FID_FIELD} < 100`, + }, + { + language: 'kuery', + query: `${FID_FIELD} > 100 and ${FID_FIELD} < 300`, + }, + { + language: 'kuery', + query: `${FID_FIELD} > 300`, + }, + ], + }, + { + label: CLS_LABEL, + id: CLS_FIELD, + columnType: FILTER_RECORDS, + columnFilters: [ + { + language: 'kuery', + query: `${CLS_FIELD} < 0.1`, + }, + { + language: 'kuery', + query: `${CLS_FIELD} > 0.1 and ${CLS_FIELD} < 0.25`, + }, + { + language: 'kuery', + query: `${CLS_FIELD} > 0.25`, + }, + ], + }, + ], + yConfig: [ + { color: statusPallete[0], forAccessor: 'y-axis-column' }, + { color: statusPallete[1], forAccessor: 'y-axis-column-1' }, + { color: statusPallete[2], forAccessor: 'y-axis-column-2' }, + ], + query: { query: 'transaction.type: "page-load"', language: 'kuery' }, + }; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/data_distribution_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/data_distribution_config.ts new file mode 100644 index 0000000000000..32099251f144f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/data_distribution_config.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConfigProps, SeriesConfig } from '../../types'; +import { + FieldLabels, + REPORT_METRIC_FIELD, + RECORDS_PERCENTAGE_FIELD, + ReportTypes, + LABEL_FIELDS_FILTER, +} from '../constants'; +import { buildPhraseFilter } from '../utils'; +import { + CLIENT_GEO_COUNTRY_NAME, + CLS_FIELD, + FCP_FIELD, + FID_FIELD, + LCP_FIELD, + PROCESSOR_EVENT, + SERVICE_ENVIRONMENT, + SERVICE_NAME, + TBT_FIELD, + TRANSACTION_DURATION, + TRANSACTION_TIME_TO_FIRST_BYTE, + TRANSACTION_TYPE, + TRANSACTION_URL, + USER_AGENT_DEVICE, + USER_AGENT_NAME, + USER_AGENT_OS, + USER_AGENT_VERSION, +} from '../constants/elasticsearch_fieldnames'; +import { + BACKEND_TIME_LABEL, + CLS_LABEL, + FCP_LABEL, + FID_LABEL, + LCP_LABEL, + PAGE_LOAD_TIME_LABEL, + PAGES_LOADED_LABEL, + TBT_LABEL, + WEB_APPLICATION_LABEL, +} from '../constants/labels'; + +export function getRumDistributionConfig({ dataView }: ConfigProps): SeriesConfig { + return { + reportType: ReportTypes.DISTRIBUTION, + defaultSeriesType: 'line', + seriesTypes: [], + xAxisColumn: { + sourceField: REPORT_METRIC_FIELD, + }, + yAxisColumns: [ + { + sourceField: RECORDS_PERCENTAGE_FIELD, + label: PAGES_LOADED_LABEL, + }, + ], + hasOperationType: false, + filterFields: [ + { + field: TRANSACTION_URL, + isNegated: false, + }, + USER_AGENT_OS, + CLIENT_GEO_COUNTRY_NAME, + USER_AGENT_DEVICE, + { + field: USER_AGENT_NAME, + nested: USER_AGENT_VERSION, + }, + LABEL_FIELDS_FILTER, + ], + breakdownFields: [ + USER_AGENT_NAME, + USER_AGENT_OS, + CLIENT_GEO_COUNTRY_NAME, + USER_AGENT_DEVICE, + SERVICE_NAME, + ], + definitionFields: [SERVICE_NAME, SERVICE_ENVIRONMENT], + metricOptions: [ + { + label: PAGE_LOAD_TIME_LABEL, + id: TRANSACTION_DURATION, + field: TRANSACTION_DURATION, + showPercentileAnnotations: true, + }, + { + label: BACKEND_TIME_LABEL, + id: TRANSACTION_TIME_TO_FIRST_BYTE, + field: TRANSACTION_TIME_TO_FIRST_BYTE, + }, + { label: FCP_LABEL, id: FCP_FIELD, field: FCP_FIELD }, + { label: TBT_LABEL, id: TBT_FIELD, field: TBT_FIELD }, + { label: LCP_LABEL, id: LCP_FIELD, field: LCP_FIELD }, + { label: FID_LABEL, id: FID_FIELD, field: FID_FIELD }, + { label: CLS_LABEL, id: CLS_FIELD, field: CLS_FIELD }, + ], + baseFilters: [ + ...buildPhraseFilter(TRANSACTION_TYPE, 'page-load', dataView), + ...buildPhraseFilter(PROCESSOR_EVENT, 'transaction', dataView), + ], + labels: { + ...FieldLabels, + [SERVICE_NAME]: WEB_APPLICATION_LABEL, + [TRANSACTION_DURATION]: PAGE_LOAD_TIME_LABEL, + }, + // rum page load transactions are always less then 60 seconds + query: { query: 'transaction.duration.us < 60000000', language: 'kuery' }, + }; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/field_formats.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/field_formats.ts new file mode 100644 index 0000000000000..25f258e17307d --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/field_formats.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FieldFormat } from '../../types'; +import { + FCP_FIELD, + FID_FIELD, + LCP_FIELD, + TBT_FIELD, + TRANSACTION_DURATION, + TRANSACTION_TIME_TO_FIRST_BYTE, +} from '../constants/elasticsearch_fieldnames'; + +export const rumFieldFormats: FieldFormat[] = [ + { + field: TRANSACTION_DURATION, + format: { + id: 'duration', + params: { + inputFormat: 'microseconds', + outputFormat: 'asSeconds', + showSuffix: true, + outputPrecision: 1, + useShortSuffix: true, + }, + }, + }, + { + field: FCP_FIELD, + format: { + id: 'duration', + params: { + inputFormat: 'milliseconds', + outputFormat: 'humanizePrecise', + showSuffix: true, + useShortSuffix: true, + }, + }, + }, + { + field: LCP_FIELD, + format: { + id: 'duration', + params: { + inputFormat: 'milliseconds', + outputFormat: 'humanizePrecise', + showSuffix: true, + useShortSuffix: true, + }, + }, + }, + { + field: TBT_FIELD, + format: { + id: 'duration', + params: { + inputFormat: 'milliseconds', + outputFormat: 'humanizePrecise', + showSuffix: true, + useShortSuffix: true, + }, + }, + }, + { + field: FID_FIELD, + format: { + id: 'duration', + params: { + inputFormat: 'milliseconds', + outputFormat: 'humanizePrecise', + showSuffix: true, + useShortSuffix: true, + }, + }, + }, + { + field: TRANSACTION_TIME_TO_FIRST_BYTE, + format: { + id: 'duration', + params: { + inputFormat: 'milliseconds', + outputFormat: 'humanizePrecise', + showSuffix: true, + useShortSuffix: true, + }, + }, + }, +]; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts new file mode 100644 index 0000000000000..4981c5c531551 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConfigProps, SeriesConfig } from '../../types'; +import { + FieldLabels, + LABEL_FIELDS_BREAKDOWN, + LABEL_FIELDS_FILTER, + OPERATION_COLUMN, + RECORDS_FIELD, + REPORT_METRIC_FIELD, + PERCENTILE, + ReportTypes, +} from '../constants'; +import { buildPhraseFilter } from '../utils'; +import { + CLIENT_GEO_COUNTRY_NAME, + CLS_FIELD, + FCP_FIELD, + FID_FIELD, + LCP_FIELD, + PROCESSOR_EVENT, + SERVICE_ENVIRONMENT, + SERVICE_NAME, + TBT_FIELD, + TRANSACTION_DURATION, + TRANSACTION_TYPE, + USER_AGENT_DEVICE, + USER_AGENT_NAME, + USER_AGENT_OS, + USER_AGENT_VERSION, + TRANSACTION_TIME_TO_FIRST_BYTE, + TRANSACTION_URL, +} from '../constants/elasticsearch_fieldnames'; +import { + BACKEND_TIME_LABEL, + CLS_LABEL, + FCP_LABEL, + FID_LABEL, + LCP_LABEL, + PAGE_LOAD_TIME_LABEL, + PAGE_VIEWS_LABEL, + TBT_LABEL, + WEB_APPLICATION_LABEL, +} from '../constants/labels'; + +export function getKPITrendsLensConfig({ dataView }: ConfigProps): SeriesConfig { + return { + defaultSeriesType: 'bar_stacked', + seriesTypes: [], + reportType: ReportTypes.KPI, + xAxisColumn: { + sourceField: '@timestamp', + }, + yAxisColumns: [ + { + sourceField: REPORT_METRIC_FIELD, + operationType: 'median', + }, + ], + hasOperationType: false, + filterFields: [ + TRANSACTION_URL, + USER_AGENT_OS, + CLIENT_GEO_COUNTRY_NAME, + USER_AGENT_DEVICE, + { + field: USER_AGENT_NAME, + nested: USER_AGENT_VERSION, + }, + LABEL_FIELDS_FILTER, + ], + breakdownFields: [ + USER_AGENT_NAME, + USER_AGENT_OS, + CLIENT_GEO_COUNTRY_NAME, + USER_AGENT_DEVICE, + PERCENTILE, + LABEL_FIELDS_BREAKDOWN, + ], + baseFilters: [ + ...buildPhraseFilter(TRANSACTION_TYPE, 'page-load', dataView), + ...buildPhraseFilter(PROCESSOR_EVENT, 'transaction', dataView), + ], + labels: { ...FieldLabels, [SERVICE_NAME]: WEB_APPLICATION_LABEL }, + definitionFields: [SERVICE_NAME, SERVICE_ENVIRONMENT], + metricOptions: [ + { field: RECORDS_FIELD, id: RECORDS_FIELD, label: PAGE_VIEWS_LABEL }, + { + label: PAGE_LOAD_TIME_LABEL, + field: TRANSACTION_DURATION, + id: TRANSACTION_DURATION, + columnType: OPERATION_COLUMN, + }, + { + label: BACKEND_TIME_LABEL, + field: TRANSACTION_TIME_TO_FIRST_BYTE, + id: TRANSACTION_TIME_TO_FIRST_BYTE, + columnType: OPERATION_COLUMN, + }, + { label: FCP_LABEL, field: FCP_FIELD, id: FCP_FIELD, columnType: OPERATION_COLUMN }, + { label: TBT_LABEL, field: TBT_FIELD, id: TBT_FIELD, columnType: OPERATION_COLUMN }, + { label: LCP_LABEL, field: LCP_FIELD, id: LCP_FIELD, columnType: OPERATION_COLUMN }, + { label: FID_LABEL, field: FID_FIELD, id: FID_FIELD, columnType: OPERATION_COLUMN }, + { label: CLS_LABEL, field: CLS_FIELD, id: CLS_FIELD, columnType: OPERATION_COLUMN }, + ], + }; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/single_metric_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/single_metric_config.ts new file mode 100644 index 0000000000000..5358144672a84 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/rum/single_metric_config.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConfigProps, SeriesConfig } from '../../types'; +import { FieldLabels } from '../constants'; +import { buildPhraseFilter } from '../utils'; +import { PROCESSOR_EVENT, TRANSACTION_TYPE } from '../constants/elasticsearch_fieldnames'; + +export function getSingleMetricConfig({ dataView }: ConfigProps): SeriesConfig { + return { + defaultSeriesType: 'line', + xAxisColumn: {}, + yAxisColumns: [ + { + operationType: 'median', + }, + ], + breakdownFields: [], + filterFields: [], + seriesTypes: [], + hasOperationType: true, + definitionFields: ['service.name'], + reportType: 'single-metric', + baseFilters: [ + ...buildPhraseFilter(TRANSACTION_TYPE, 'page-load', dataView), + ...buildPhraseFilter(PROCESSOR_EVENT, 'transaction', dataView), + ], + metricOptions: [ + { + id: 'page_views', + field: 'Records', + label: 'Total page views', + }, + { + id: 'page_load_time', + field: 'transaction.duration.us', + label: 'Page load time', + }, + { + id: 'backend_time', + field: 'transaction.marks.agent.timeToFirstByte', + label: 'Backend time', + }, + { + id: 'frontend_time', + field: 'transaction.marks.agent.timeToFirstByte', + label: 'Frontend time', + }, + ], + labels: FieldLabels, + }; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts new file mode 100644 index 0000000000000..412bf2ef87f6b --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConfigProps, SeriesConfig } from '../../types'; +import { + FieldLabels, + REPORT_METRIC_FIELD, + RECORDS_PERCENTAGE_FIELD, + ReportTypes, +} from '../constants'; +import { + CLS_LABEL, + DCL_LABEL, + DOCUMENT_ONLOAD_LABEL, + FCP_LABEL, + LCP_LABEL, + MONITORS_DURATION_LABEL, + PINGS_LABEL, +} from '../constants/labels'; +import { + MONITOR_DURATION_US, + SYNTHETICS_CLS, + SYNTHETICS_DCL, + SYNTHETICS_DOCUMENT_ONLOAD, + SYNTHETICS_FCP, + SYNTHETICS_LCP, +} from '../constants/field_names/synthetics'; +import { buildExistsFilter } from '../utils'; + +export function getSyntheticsDistributionConfig({ series, dataView }: ConfigProps): SeriesConfig { + return { + reportType: ReportTypes.DISTRIBUTION, + defaultSeriesType: series?.seriesType || 'line', + seriesTypes: [], + xAxisColumn: { + sourceField: REPORT_METRIC_FIELD, + }, + yAxisColumns: [ + { + sourceField: RECORDS_PERCENTAGE_FIELD, + label: PINGS_LABEL, + }, + ], + hasOperationType: false, + filterFields: ['monitor.type', 'observer.geo.name', 'tags', 'url.full'], + breakdownFields: [ + 'observer.geo.name', + 'monitor.name', + 'monitor.id', + 'monitor.type', + 'tags', + 'url.port', + ], + baseFilters: [], + definitionFields: [ + { field: 'monitor.name', nested: 'synthetics.step.name.keyword', singleSelection: true }, + { field: 'url.full', filters: buildExistsFilter('summary.up', dataView) }, + ], + metricOptions: [ + { + label: MONITORS_DURATION_LABEL, + id: MONITOR_DURATION_US, + field: MONITOR_DURATION_US, + }, + { + label: LCP_LABEL, + field: SYNTHETICS_LCP, + id: SYNTHETICS_LCP, + }, + { + label: FCP_LABEL, + field: SYNTHETICS_FCP, + id: SYNTHETICS_FCP, + }, + { + label: DCL_LABEL, + field: SYNTHETICS_DCL, + id: SYNTHETICS_DCL, + }, + { + label: DOCUMENT_ONLOAD_LABEL, + field: SYNTHETICS_DOCUMENT_ONLOAD, + id: SYNTHETICS_DOCUMENT_ONLOAD, + }, + { + label: CLS_LABEL, + field: SYNTHETICS_CLS, + id: SYNTHETICS_CLS, + }, + ], + labels: { ...FieldLabels, 'monitor.duration.us': MONITORS_DURATION_LABEL }, + }; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/field_formats.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/field_formats.ts new file mode 100644 index 0000000000000..9744a08c7ced9 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/field_formats.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FieldFormat } from '../../types'; +import { + SYNTHETICS_DCL, + SYNTHETICS_DOCUMENT_ONLOAD, + SYNTHETICS_FCP, + SYNTHETICS_LCP, + SYNTHETICS_STEP_DURATION, +} from '../constants/field_names/synthetics'; + +export const MS_TO_HUMANIZE_PRECISE = { + inputFormat: 'milliseconds' as const, + outputFormat: 'humanizePrecise' as const, + outputPrecision: 1, + showSuffix: true, + useShortSuffix: true, +}; + +export const syntheticsFieldFormats: FieldFormat[] = [ + { + field: 'monitor.duration.us', + format: { + id: 'duration', + params: { + inputFormat: 'microseconds', + outputFormat: 'humanizePrecise', + outputPrecision: 1, + showSuffix: true, + useShortSuffix: true, + }, + }, + }, + { + field: SYNTHETICS_STEP_DURATION, + format: { + id: 'duration', + params: { + inputFormat: 'microseconds', + outputFormat: 'humanizePrecise', + outputPrecision: 1, + showSuffix: true, + useShortSuffix: true, + }, + }, + }, + { + field: SYNTHETICS_LCP, + format: { + id: 'duration', + params: { + inputFormat: 'microseconds', + outputFormat: 'humanizePrecise', + outputPrecision: 1, + showSuffix: true, + useShortSuffix: true, + }, + }, + }, + { + field: SYNTHETICS_FCP, + format: { + id: 'duration', + params: { + inputFormat: 'microseconds', + outputFormat: 'humanizePrecise', + outputPrecision: 1, + showSuffix: true, + useShortSuffix: true, + }, + }, + }, + { + field: SYNTHETICS_DOCUMENT_ONLOAD, + format: { + id: 'duration', + params: { + inputFormat: 'microseconds', + outputFormat: 'humanizePrecise', + outputPrecision: 1, + showSuffix: true, + useShortSuffix: true, + }, + }, + }, + { + field: SYNTHETICS_DCL, + format: { + id: 'duration', + params: { + inputFormat: 'microseconds', + outputFormat: 'humanizePrecise', + outputPrecision: 1, + showSuffix: true, + useShortSuffix: true, + }, + }, + }, +]; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/heatmap_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/heatmap_config.ts new file mode 100644 index 0000000000000..1c41aaa8d4db9 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/heatmap_config.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConfigProps, SeriesConfig } from '../../types'; +import { FieldLabels, RECORDS_FIELD, REPORT_METRIC_FIELD, ReportTypes } from '../constants'; +import { DOWN_LABEL, UP_LABEL } from '../constants/labels'; +import { SYNTHETICS_STEP_NAME } from '../constants/field_names/synthetics'; +import { buildExistsFilter } from '../utils'; + +const SUMMARY_UP = 'summary.up'; +const SUMMARY_DOWN = 'summary.down'; + +export function getSyntheticsHeatmapConfig({ dataView }: ConfigProps): SeriesConfig { + return { + reportType: ReportTypes.HEATMAP, + defaultSeriesType: 'bar_stacked', + seriesTypes: [], + xAxisColumn: { + sourceField: '@timestamp', + }, + yAxisColumns: [ + { + sourceField: REPORT_METRIC_FIELD, + operationType: 'median', + }, + ], + hasOperationType: false, + filterFields: ['observer.geo.name', 'monitor.type', 'tags', 'url.full'], + breakdownFields: ['observer.geo.name', 'monitor.type', 'monitor.name', SYNTHETICS_STEP_NAME], + baseFilters: [], + definitionFields: [ + { field: 'monitor.name' }, + { field: 'url.full', filters: buildExistsFilter('summary.up', dataView) }, + ], + metricOptions: [ + { + label: 'Failed tests', + id: 'failed_tests', + columnFilter: { language: 'kuery', query: 'summary.down > 0' }, + format: 'number', + field: RECORDS_FIELD, + }, + ], + labels: { ...FieldLabels, [SUMMARY_UP]: UP_LABEL, [SUMMARY_DOWN]: DOWN_LABEL }, + }; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts new file mode 100644 index 0000000000000..f6187e1117f99 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts @@ -0,0 +1,222 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ColumnFilter, ConfigProps, SeriesConfig } from '../../types'; +import { + FieldLabels, + OPERATION_COLUMN, + REPORT_METRIC_FIELD, + PERCENTILE, + ReportTypes, + FORMULA_COLUMN, +} from '../constants'; +import { + CLS_LABEL, + DCL_LABEL, + DOWN_LABEL, + FCP_LABEL, + LCP_LABEL, + MONITORS_DURATION_LABEL, + STEP_DURATION_LABEL, + UP_LABEL, + PAGE_LOAD_TIME_LABEL, + NETWORK_TIMINGS_LABEL, +} from '../constants/labels'; +import { + MONITOR_DURATION_US, + NETWORK_TIMINGS_FIELDS, + SYNTHETICS_CLS, + SYNTHETICS_DCL, + SYNTHETICS_DOCUMENT_ONLOAD, + SYNTHETICS_FCP, + SYNTHETICS_LCP, + SYNTHETICS_STEP_DURATION, + SYNTHETICS_STEP_NAME, +} from '../constants/field_names/synthetics'; +import { buildExistsFilter } from '../utils'; +const SUMMARY_UP = 'summary.up'; +const SUMMARY_DOWN = 'summary.down'; + +export const isStepLevelMetric = (metric?: string) => { + if (!metric) { + return false; + } + return [ + SYNTHETICS_LCP, + SYNTHETICS_FCP, + SYNTHETICS_CLS, + SYNTHETICS_DCL, + SYNTHETICS_STEP_DURATION, + SYNTHETICS_DOCUMENT_ONLOAD, + ].includes(metric); +}; +export function getSyntheticsKPIConfig({ dataView }: ConfigProps): SeriesConfig { + return { + reportType: ReportTypes.KPI, + defaultSeriesType: 'bar_stacked', + seriesTypes: [], + xAxisColumn: { + sourceField: '@timestamp', + }, + yAxisColumns: [ + { + sourceField: REPORT_METRIC_FIELD, + operationType: 'median', + }, + ], + hasOperationType: true, + filterFields: ['observer.geo.name', 'monitor.type', 'tags', 'url.full'], + breakdownFields: [ + 'observer.geo.name', + 'monitor.type', + 'monitor.name', + SYNTHETICS_STEP_NAME, + PERCENTILE, + ], + baseFilters: [], + definitionFields: [ + { field: 'monitor.name', nested: SYNTHETICS_STEP_NAME, singleSelection: true }, + { field: 'url.full', filters: buildExistsFilter('summary.up', dataView) }, + ], + metricOptions: [ + { + label: MONITORS_DURATION_LABEL, + field: MONITOR_DURATION_US, + id: MONITOR_DURATION_US, + columnType: OPERATION_COLUMN, + }, + { + label: 'Monitor availability', + id: 'monitor_availability', + columnType: FORMULA_COLUMN, + formula: "1- (count(kql='summary.down > 0') / count(kql='summary: *'))", + }, + { + label: 'Monitor Errors', + id: 'monitor_errors', + columnType: OPERATION_COLUMN, + field: 'state.id', + columnFilters: [ + { + language: 'kuery', + query: `summary.down > 0`, + }, + ], + }, + { + label: 'Monitor Complete', + id: 'state.up', + field: 'state.up', + columnType: OPERATION_COLUMN, + columnFilters: [ + { + language: 'kuery', + query: `summary: * and summary.down: 0 and monitor.status: "up"`, + }, + ], + }, + { + label: 'Total runs', + id: 'monitor.check_group', + field: 'monitor.check_group', + columnType: OPERATION_COLUMN, + columnFilters: [ + { + language: 'kuery', + query: `summary: *`, + }, + ], + }, + { + field: SUMMARY_UP, + id: SUMMARY_UP, + label: UP_LABEL, + columnType: OPERATION_COLUMN, + palette: { type: 'palette', name: 'status' }, + }, + { + field: SUMMARY_DOWN, + id: SUMMARY_DOWN, + label: DOWN_LABEL, + columnType: OPERATION_COLUMN, + palette: { type: 'palette', name: 'status' }, + }, + { + label: STEP_DURATION_LABEL, + field: SYNTHETICS_STEP_DURATION, + id: SYNTHETICS_STEP_DURATION, + columnType: OPERATION_COLUMN, + columnFilters: [STEP_END_FILTER], + }, + { + label: LCP_LABEL, + field: SYNTHETICS_LCP, + id: SYNTHETICS_LCP, + columnType: OPERATION_COLUMN, + columnFilters: getStepMetricColumnFilter(SYNTHETICS_LCP), + }, + { + label: FCP_LABEL, + field: SYNTHETICS_FCP, + id: SYNTHETICS_FCP, + columnType: OPERATION_COLUMN, + columnFilters: getStepMetricColumnFilter(SYNTHETICS_FCP), + }, + { + label: DCL_LABEL, + field: SYNTHETICS_DCL, + id: SYNTHETICS_DCL, + columnType: OPERATION_COLUMN, + columnFilters: getStepMetricColumnFilter(SYNTHETICS_DCL), + }, + { + label: PAGE_LOAD_TIME_LABEL, + field: SYNTHETICS_DOCUMENT_ONLOAD, + id: SYNTHETICS_DOCUMENT_ONLOAD, + columnType: OPERATION_COLUMN, + columnFilters: getStepMetricColumnFilter(SYNTHETICS_DOCUMENT_ONLOAD), + }, + { + label: CLS_LABEL, + field: SYNTHETICS_CLS, + id: SYNTHETICS_CLS, + columnType: OPERATION_COLUMN, + columnFilters: getStepMetricColumnFilter(SYNTHETICS_CLS), + }, + { + label: NETWORK_TIMINGS_LABEL, + id: 'network_timings', + columnType: OPERATION_COLUMN, + items: NETWORK_TIMINGS_FIELDS.map((field) => ({ + label: FieldLabels[field] ?? field, + field, + id: field, + columnType: OPERATION_COLUMN, + columnFilters: getStepMetricColumnFilter(field, 'journey/network_info'), + })), + }, + ], + labels: { ...FieldLabels, [SUMMARY_UP]: UP_LABEL, [SUMMARY_DOWN]: DOWN_LABEL }, + }; +} + +const getStepMetricColumnFilter = ( + field: string, + stepType: 'step/metrics' | 'step/end' | 'journey/network_info' = 'step/metrics' +): ColumnFilter[] => { + return [ + { + language: 'kuery', + query: `synthetics.type: ${stepType} and ${field}: * and ${field} > 0`, + }, + ]; +}; + +const STEP_END_FILTER: ColumnFilter = { + language: 'kuery', + query: `synthetics.type: step/end`, +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/runtime_fields.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/runtime_fields.ts new file mode 100644 index 0000000000000..16be09ed474bd --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/runtime_fields.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RuntimeField } from '@kbn/data-views-plugin/public'; +import { MS_TO_HUMANIZE_PRECISE } from './field_formats'; +import { + SYNTHETICS_DNS_TIMINGS, + SYNTHETICS_BLOCKED_TIMINGS, + SYNTHETICS_CONNECT_TIMINGS, + SYNTHETICS_TOTAL_TIMINGS, + SYNTHETICS_RECEIVE_TIMINGS, + SYNTHETICS_SEND_TIMINGS, + SYNTHETICS_WAIT_TIMINGS, + SYNTHETICS_SSL_TIMINGS, +} from '../constants/field_names/synthetics'; + +const LONG_FIELD = { + type: 'long' as const, + format: { + id: 'duration', + params: MS_TO_HUMANIZE_PRECISE, + }, +}; + +export const syntheticsRuntimeFields: Array<{ name: string; field: RuntimeField }> = [ + { + name: SYNTHETICS_DNS_TIMINGS, + field: LONG_FIELD, + }, + { + name: SYNTHETICS_BLOCKED_TIMINGS, + field: LONG_FIELD, + }, + { + name: SYNTHETICS_CONNECT_TIMINGS, + field: LONG_FIELD, + }, + { + name: SYNTHETICS_TOTAL_TIMINGS, + field: LONG_FIELD, + }, + { + name: SYNTHETICS_RECEIVE_TIMINGS, + field: LONG_FIELD, + }, + { + name: SYNTHETICS_SEND_TIMINGS, + field: LONG_FIELD, + }, + { + name: SYNTHETICS_WAIT_TIMINGS, + field: LONG_FIELD, + }, + { + name: SYNTHETICS_SSL_TIMINGS, + field: LONG_FIELD, + }, +]; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/single_metric_config.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/single_metric_config.ts new file mode 100644 index 0000000000000..014838bb39c87 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/synthetics/single_metric_config.ts @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { LegacyMetricState } from '@kbn/lens-plugin/common'; +import { euiPaletteForStatus } from '@elastic/eui'; +import { + SYNTHETICS_STEP_DURATION, + SYNTHETICS_STEP_NAME, +} from '../constants/field_names/synthetics'; +import { ConfigProps, SeriesConfig } from '../../types'; +import { FieldLabels, FORMULA_COLUMN, RECORDS_FIELD } from '../constants'; +import { buildExistsFilter } from '../utils'; + +export function getSyntheticsSingleMetricConfig({ dataView }: ConfigProps): SeriesConfig { + return { + defaultSeriesType: 'line', + xAxisColumn: {}, + yAxisColumns: [ + { + operationType: 'median', + }, + ], + breakdownFields: [], + filterFields: [], + seriesTypes: [], + hasOperationType: true, + definitionFields: [ + { field: 'monitor.name', nested: SYNTHETICS_STEP_NAME, singleSelection: true }, + { field: 'url.full', filters: buildExistsFilter('summary.up', dataView) }, + ], + reportType: 'single-metric', + baseFilters: [], + metricOptions: [ + { + id: 'monitor_availability', + columnType: FORMULA_COLUMN, + label: 'Availability', + formula: "1- (count(kql='summary.down > 0') / count())", + metricStateOptions: { + colorMode: 'Labels', + palette: { + name: 'custom', + type: 'palette', + params: { + steps: 3, + name: 'custom', + reverse: false, + rangeType: 'number', + rangeMin: 0, + rangeMax: 1, + progression: 'fixed', + stops: [ + { color: '#cc5642', stop: 0.9 }, + { color: '#d6bf57', stop: 0.95 }, + { color: '#209280', stop: 1.9903347477604902 }, + ], + colorStops: [ + { color: '#cc5642', stop: 0.8 }, + { color: '#d6bf57', stop: 0.9 }, + { color: '#209280', stop: 0.95 }, + ], + continuity: 'above', + maxSteps: 5, + }, + }, + titlePosition: 'bottom', + }, + columnFilter: { language: 'kuery', query: 'summary.up: *' }, + }, + { + id: 'monitor_duration', + field: 'monitor.duration.us', + label: i18n.translate('xpack.exploratoryView.expView.avgDuration', { + defaultMessage: 'Avg. Duration', + }), + metricStateOptions: { + titlePosition: 'bottom', + }, + columnFilter: { language: 'kuery', query: 'summary.up: *' }, + }, + { + id: 'step_duration', + field: SYNTHETICS_STEP_DURATION, + label: i18n.translate('xpack.exploratoryView.expView.stepDuration', { + defaultMessage: 'Total step duration', + }), + metricStateOptions: { + titlePosition: 'bottom', + textAlign: 'center', + }, + }, + { + id: 'monitor_total_runs', + label: i18n.translate('xpack.exploratoryView.expView.totalRuns', { + defaultMessage: 'Total Runs', + }), + metricStateOptions: { + titlePosition: 'bottom', + }, + columnType: FORMULA_COLUMN, + formula: "unique_count(monitor.check_group, kql='summary: *')", + format: 'number', + }, + { + id: 'monitor_complete', + label: i18n.translate('xpack.exploratoryView.expView.complete', { + defaultMessage: 'Complete', + }), + metricStateOptions: { + titlePosition: 'bottom', + }, + columnType: FORMULA_COLUMN, + formula: 'unique_count(monitor.check_group, kql=\'monitor.status: "up"\')', + format: 'number', + }, + { + id: 'monitor_errors', + label: i18n.translate('xpack.exploratoryView.expView.errors', { + defaultMessage: 'Errors', + }), + metricStateOptions: { + titlePosition: 'bottom', + colorMode: 'Labels', + palette: getColorPalette('danger'), + }, + columnType: FORMULA_COLUMN, + formula: 'unique_count(state.id, kql=\'monitor.status: "down"\')', + format: 'number', + }, + { + id: 'monitor_failed_tests', + label: i18n.translate('xpack.exploratoryView.expView.failedTests', { + defaultMessage: 'Failed tests', + }), + metricStateOptions: { + titlePosition: 'bottom', + }, + field: RECORDS_FIELD, + format: 'number', + columnFilter: { language: 'kuery', query: 'summary.down > 0' }, + }, + ], + labels: FieldLabels, + }; +} + +export const getColorPalette = ( + color: 'danger' | 'warning' | 'success' | string +): LegacyMetricState['palette'] => { + const statusPalette = euiPaletteForStatus(5); + + let valueColor = color ?? statusPalette[3]; + if (color === 'danger') { + valueColor = statusPalette[3]; + } + + return { + name: 'custom', + type: 'palette', + params: { + steps: 3, + name: 'custom', + reverse: false, + rangeType: 'number', + rangeMin: 0, + progression: 'fixed', + stops: [{ color: valueColor, stop: 100 }], + colorStops: [{ color: valueColor, stop: 0 }], + continuity: 'above', + maxSteps: 5, + }, + }; +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/mobile_test_attribute.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/mobile_test_attribute.ts new file mode 100644 index 0000000000000..c083962b0e21b --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/mobile_test_attribute.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockDataView } from '../../rtl_helpers'; + +export const testMobileKPIAttr = { + title: 'Prefilled from exploratory view app', + description: '', + references: [], + visualizationType: 'lnsXY', + state: { + adHocDataViews: { [mockDataView.title]: mockDataView.toSpec(false) }, + internalReferences: [ + { + id: 'apm-*', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'apm-*', + name: 'indexpattern-datasource-layer-layer0', + type: 'index-pattern', + }, + ], + datasourceStates: { + formBased: { + layers: { + layer0: { + columnOrder: ['x-axis-column-layer0', 'y-axis-column-layer0-0'], + columns: { + 'x-axis-column-layer0': { + sourceField: '@timestamp', + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { interval: 'auto', includeEmptyRows: true }, + scale: 'interval', + }, + 'y-axis-column-layer0-0': { + isBucketed: false, + label: 'test-series', + operationType: 'median', + params: {}, + scale: 'ratio', + sourceField: 'system.memory.usage', + dataType: 'number', + filter: { + query: + 'service.name: "ios-integration-testing" and agent.name: (iOS/swift or open-telemetry/swift) and processor.event: metric', + language: 'kuery', + }, + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + visualization: { + legend: { + isVisible: true, + showSingleSeries: true, + position: 'right', + legendSize: 'auto', + shouldTruncate: false, + }, + valueLabels: 'hide', + fittingFunction: 'Linear', + curveType: 'CURVE_MONOTONE_X', + axisTitlesVisibilitySettings: { x: false, yLeft: true, yRight: true }, + tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, + gridlinesVisibilitySettings: { x: false, yLeft: true, yRight: true }, + preferredSeriesType: 'line', + layers: [ + { + accessors: ['y-axis-column-layer0-0'], + layerId: 'layer0', + layerType: 'data', + palette: undefined, + seriesType: 'line', + yConfig: [{ forAccessor: 'y-axis-column-layer0-0', color: 'green', axisMode: 'left' }], + xAccessor: 'x-axis-column-layer0', + }, + ], + }, + query: { + query: + 'service.name: "ios-integration-testing" and agent.name: (iOS/swift or open-telemetry/swift)', + language: 'kuery', + }, + filters: [], + }, +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts new file mode 100644 index 0000000000000..7c054a908ba3b --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts @@ -0,0 +1,342 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { mockDataView } from '../../rtl_helpers'; +import { RECORDS_FIELD } from '../constants'; + +export const sampleAttribute = { + description: '', + references: [], + state: { + internalReferences: [ + { + id: 'apm-*', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'apm-*', + name: 'indexpattern-datasource-layer-layer0', + type: 'index-pattern', + }, + { + id: 'apm-*', + name: 'indexpattern-datasource-layer-layer0-reference-lines', + type: 'index-pattern', + }, + ], + adHocDataViews: { [mockDataView.title]: mockDataView.toSpec(false) }, + datasourceStates: { + formBased: { + layers: { + layer0: { + columnOrder: [ + 'x-axis-column-layer0', + 'y-axis-column-layer0-0', + 'y-axis-column-layer0X0', + 'y-axis-column-layer0X1', + 'y-axis-column-layer0X2', + 'y-axis-column-layer0X3', + ], + columns: { + 'x-axis-column-layer0': { + dataType: 'number', + isBucketed: true, + label: 'Page load time', + operationType: 'range', + params: { + maxBars: 'auto', + ranges: [ + { + from: 0, + label: '', + to: 1000, + }, + ], + type: 'histogram', + }, + scale: 'interval', + sourceField: 'transaction.duration.us', + }, + 'y-axis-column-layer0-0': { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.type : *', + }, + isBucketed: false, + label: 'test-series', + operationType: 'formula', + params: { + format: { + id: 'percent', + params: { + decimals: 0, + }, + }, + formula: + "count(kql='transaction.type: page-load and processor.event: transaction and transaction.type : *') / overall_sum(count(kql='transaction.type: page-load and processor.event: transaction and transaction.type : *'))", + isFormulaBroken: false, + }, + references: ['y-axis-column-layer0X3'], + }, + 'y-axis-column-layer0X0': { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.type : *', + }, + isBucketed: false, + label: 'Part of Pages loaded', + operationType: 'count', + params: { + emptyAsNull: false, + }, + scale: 'ratio', + sourceField: RECORDS_FIELD, + timeScale: undefined, + timeShift: undefined, + }, + 'y-axis-column-layer0X1': { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.type : *', + }, + isBucketed: false, + label: 'Part of Pages loaded', + operationType: 'count', + params: { + emptyAsNull: false, + }, + scale: 'ratio', + sourceField: RECORDS_FIELD, + timeScale: undefined, + timeShift: undefined, + }, + 'y-axis-column-layer0X2': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: 'Part of Pages loaded', + operationType: 'overall_sum', + params: undefined, + references: ['y-axis-column-layer0X1'], + scale: 'ratio', + }, + 'y-axis-column-layer0X3': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: 'Part of Pages loaded', + operationType: 'math', + params: { + tinymathAst: { + args: ['y-axis-column-layer0X0', 'y-axis-column-layer0X2'], + location: { + max: 212, + min: 0, + }, + name: 'divide', + text: "count(kql='transaction.type: page-load and processor.event: transaction and transaction.type : *') / overall_sum(count(kql='transaction.type: page-load and processor.event: transaction and transaction.type : *'))", + type: 'function', + }, + }, + references: ['y-axis-column-layer0X0', 'y-axis-column-layer0X2'], + scale: 'ratio', + }, + }, + incompleteColumns: {}, + }, + 'layer0-reference-lines': { + columnOrder: [ + '50th-percentile-reference-line-layer0-reference-lines', + '75th-percentile-reference-line-layer0-reference-lines', + '90th-percentile-reference-line-layer0-reference-lines', + '95th-percentile-reference-line-layer0-reference-lines', + '99th-percentile-reference-line-layer0-reference-lines', + ], + columns: { + '50th-percentile-reference-line-layer0-reference-lines': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: '50th', + operationType: 'percentile', + params: { + percentile: 50, + }, + scale: 'ratio', + sourceField: 'transaction.duration.us', + }, + '75th-percentile-reference-line-layer0-reference-lines': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: '75th', + operationType: 'percentile', + params: { + percentile: 75, + }, + scale: 'ratio', + sourceField: 'transaction.duration.us', + }, + '90th-percentile-reference-line-layer0-reference-lines': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: '90th', + operationType: 'percentile', + params: { + percentile: 90, + }, + scale: 'ratio', + sourceField: 'transaction.duration.us', + }, + '95th-percentile-reference-line-layer0-reference-lines': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: '95th', + operationType: 'percentile', + params: { + percentile: 95, + }, + scale: 'ratio', + sourceField: 'transaction.duration.us', + }, + '99th-percentile-reference-line-layer0-reference-lines': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: '99th', + operationType: 'percentile', + params: { + percentile: 99, + }, + scale: 'ratio', + sourceField: 'transaction.duration.us', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { + language: 'kuery', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.type : * and transaction.duration.us < 60000000', + }, + visualization: { + axisTitlesVisibilitySettings: { + x: false, + yLeft: true, + yRight: true, + }, + curveType: 'CURVE_MONOTONE_X', + fittingFunction: 'Linear', + gridlinesVisibilitySettings: { + x: false, + yLeft: true, + yRight: true, + }, + layers: [ + { + accessors: ['y-axis-column-layer0-0'], + layerId: 'layer0', + layerType: 'data', + palette: undefined, + seriesType: 'line', + xAccessor: 'x-axis-column-layer0', + yConfig: [ + { + color: 'green', + forAccessor: 'y-axis-column-layer0-0', + axisMode: 'left', + }, + ], + }, + { + accessors: [ + '50th-percentile-reference-line-layer0-reference-lines', + '75th-percentile-reference-line-layer0-reference-lines', + '90th-percentile-reference-line-layer0-reference-lines', + '95th-percentile-reference-line-layer0-reference-lines', + '99th-percentile-reference-line-layer0-reference-lines', + ], + layerId: 'layer0-reference-lines', + layerType: 'referenceLine', + yConfig: [ + { + axisMode: 'bottom', + color: '#6092C0', + forAccessor: '50th-percentile-reference-line-layer0-reference-lines', + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + }, + { + axisMode: 'bottom', + color: '#6092C0', + forAccessor: '75th-percentile-reference-line-layer0-reference-lines', + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + }, + { + axisMode: 'bottom', + color: '#6092C0', + forAccessor: '90th-percentile-reference-line-layer0-reference-lines', + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + }, + { + axisMode: 'bottom', + color: '#6092C0', + forAccessor: '95th-percentile-reference-line-layer0-reference-lines', + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + }, + { + axisMode: 'bottom', + color: '#6092C0', + forAccessor: '99th-percentile-reference-line-layer0-reference-lines', + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + }, + ], + }, + ], + legend: { + isVisible: true, + position: 'right', + showSingleSeries: true, + legendSize: 'auto', + shouldTruncate: false, + }, + preferredSeriesType: 'line', + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + valueLabels: 'hide', + }, + }, + title: 'Prefilled from exploratory view app', + visualizationType: 'lnsXY', +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts new file mode 100644 index 0000000000000..5c09be2382425 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { mockDataView } from '../../rtl_helpers'; +import { RECORDS_FIELD } from '../constants'; + +export const sampleAttributeCoreWebVital = { + description: '', + references: [], + state: { + internalReferences: [ + { + id: 'apm-*', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'apm-*', + name: 'indexpattern-datasource-layer-layer0', + type: 'index-pattern', + }, + ], + adHocDataViews: { [mockDataView.title]: mockDataView.toSpec(false) }, + datasourceStates: { + formBased: { + layers: { + layer0: { + columnOrder: [ + 'x-axis-column-layer0', + 'y-axis-column-layer0-0', + 'y-axis-column-1', + 'y-axis-column-2', + ], + columns: { + 'x-axis-column-layer0': { + dataType: 'string', + isBucketed: true, + label: 'Operating system', + operationType: 'terms', + params: { + missingBucket: false, + orderBy: { + columnId: 'y-axis-column-layer0-0', + type: 'column', + }, + orderDirection: 'desc', + otherBucket: true, + size: 10, + }, + scale: 'ordinal', + sourceField: 'user_agent.os.name', + }, + 'y-axis-column-1': { + dataType: 'number', + filter: { + language: 'kuery', + query: + 'transaction.marks.agent.largestContentfulPaint > 2500 and transaction.marks.agent.largestContentfulPaint < 4000', + }, + isBucketed: false, + label: 'Average', + operationType: 'count', + scale: 'ratio', + sourceField: RECORDS_FIELD, + }, + 'y-axis-column-2': { + dataType: 'number', + filter: { + language: 'kuery', + query: 'transaction.marks.agent.largestContentfulPaint > 4000', + }, + isBucketed: false, + label: 'Poor', + operationType: 'count', + scale: 'ratio', + sourceField: RECORDS_FIELD, + }, + 'y-axis-column-layer0-0': { + dataType: 'number', + filter: { + language: 'kuery', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.marks.agent.largestContentfulPaint < 2500', + }, + isBucketed: false, + label: 'Good', + operationType: 'count', + scale: 'ratio', + sourceField: RECORDS_FIELD, + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { + language: 'kuery', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.type: "page-load"', + }, + visualization: { + axisTitlesVisibilitySettings: { + x: false, + yLeft: true, + yRight: true, + }, + curveType: 'CURVE_MONOTONE_X', + fittingFunction: 'Linear', + gridlinesVisibilitySettings: { + x: false, + yLeft: true, + yRight: true, + }, + layers: [ + { + accessors: ['y-axis-column-layer0-0', 'y-axis-column-1', 'y-axis-column-2'], + layerId: 'layer0', + layerType: 'data', + palette: undefined, + seriesType: 'bar_horizontal_percentage_stacked', + xAccessor: 'x-axis-column-layer0', + yConfig: [ + { + color: '#209280', + forAccessor: 'y-axis-column', + }, + { + color: '#d6bf57', + forAccessor: 'y-axis-column-1', + }, + { + color: '#cc5642', + forAccessor: 'y-axis-column-2', + }, + ], + }, + ], + legend: { + isVisible: true, + showSingleSeries: true, + position: 'right', + shouldTruncate: false, + legendSize: 'auto', + }, + preferredSeriesType: 'line', + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + valueLabels: 'hide', + }, + }, + title: 'Prefilled from exploratory view app', + visualizationType: 'lnsXY', +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts new file mode 100644 index 0000000000000..49787f1304859 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts @@ -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 { mockDataView } from '../../rtl_helpers'; +import { RECORDS_FIELD } from '../constants'; + +export const sampleAttributeKpi = { + description: '', + references: [], + state: { + internalReferences: [ + { + id: 'apm-*', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'apm-*', + name: 'indexpattern-datasource-layer-layer0', + type: 'index-pattern', + }, + ], + adHocDataViews: { [mockDataView.title]: mockDataView.toSpec(false) }, + datasourceStates: { + formBased: { + layers: { + layer0: { + columnOrder: ['x-axis-column-layer0', 'y-axis-column-layer0-0'], + columns: { + 'x-axis-column-layer0': { + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { + interval: 'auto', + includeEmptyRows: true, + }, + scale: 'interval', + sourceField: '@timestamp', + }, + 'y-axis-column-layer0-0': { + dataType: 'number', + filter: { + language: 'kuery', + query: 'transaction.type: page-load and processor.event: transaction', + }, + isBucketed: false, + label: 'test-series', + operationType: 'count', + scale: 'ratio', + sourceField: RECORDS_FIELD, + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { + language: 'kuery', + query: 'transaction.type: page-load and processor.event: transaction', + }, + visualization: { + axisTitlesVisibilitySettings: { + x: false, + yLeft: true, + yRight: true, + }, + curveType: 'CURVE_MONOTONE_X', + fittingFunction: 'Linear', + gridlinesVisibilitySettings: { + x: false, + yLeft: true, + yRight: true, + }, + layers: [ + { + accessors: ['y-axis-column-layer0-0'], + layerId: 'layer0', + layerType: 'data', + palette: undefined, + seriesType: 'line', + xAccessor: 'x-axis-column-layer0', + yConfig: [ + { + color: 'green', + forAccessor: 'y-axis-column-layer0-0', + axisMode: 'left', + }, + ], + }, + ], + legend: { + isVisible: true, + showSingleSeries: true, + position: 'right', + legendSize: 'auto', + shouldTruncate: false, + }, + preferredSeriesType: 'line', + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + valueLabels: 'hide', + }, + }, + title: 'Prefilled from exploratory view app', + visualizationType: 'lnsXY', +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_with_reference_lines.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_with_reference_lines.ts new file mode 100644 index 0000000000000..66be0cf60083d --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_with_reference_lines.ts @@ -0,0 +1,342 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { mockDataView } from '../../rtl_helpers'; +import { RECORDS_FIELD } from '../constants'; + +export const sampleAttributeWithReferenceLines = { + description: '', + references: [], + state: { + internalReferences: [ + { + id: 'apm-*', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'apm-*', + name: 'indexpattern-datasource-layer-layer0', + type: 'index-pattern', + }, + { + id: 'apm-*', + name: 'indexpattern-datasource-layer-layer0-reference-lines', + type: 'index-pattern', + }, + ], + adHocDataViews: { [mockDataView.title]: mockDataView.toSpec(false) }, + datasourceStates: { + formBased: { + layers: { + layer0: { + columnOrder: [ + 'x-axis-column-layer0', + 'y-axis-column-layer0-0', + 'y-axis-column-layer0X0', + 'y-axis-column-layer0X1', + 'y-axis-column-layer0X2', + 'y-axis-column-layer0X3', + ], + columns: { + 'x-axis-column-layer0': { + dataType: 'number', + isBucketed: true, + label: 'Page load time', + operationType: 'range', + params: { + maxBars: 'auto', + ranges: [ + { + from: 0, + label: '', + to: 1000, + }, + ], + type: 'histogram', + }, + scale: 'interval', + sourceField: 'transaction.duration.us', + }, + 'y-axis-column-layer0-0': { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.type : * and service.name: (elastic or kibana)', + }, + isBucketed: false, + label: 'test-series', + operationType: 'formula', + params: { + format: { + id: 'percent', + params: { + decimals: 0, + }, + }, + formula: + "count(kql='transaction.type: page-load and processor.event: transaction and transaction.type : * and service.name: (elastic or kibana)') / overall_sum(count(kql='transaction.type: page-load and processor.event: transaction and transaction.type : * and service.name: (elastic or kibana)'))", + isFormulaBroken: false, + }, + references: ['y-axis-column-layer0X3'], + }, + 'y-axis-column-layer0X0': { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.type : * and service.name: (elastic or kibana)', + }, + isBucketed: false, + label: 'Part of Pages loaded', + operationType: 'count', + params: { + emptyAsNull: false, + }, + scale: 'ratio', + sourceField: RECORDS_FIELD, + timeScale: undefined, + timeShift: undefined, + }, + 'y-axis-column-layer0X1': { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.type : * and service.name: (elastic or kibana)', + }, + isBucketed: false, + label: 'Part of Pages loaded', + operationType: 'count', + params: { + emptyAsNull: false, + }, + scale: 'ratio', + sourceField: RECORDS_FIELD, + timeScale: undefined, + timeShift: undefined, + }, + 'y-axis-column-layer0X2': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: 'Part of Pages loaded', + operationType: 'overall_sum', + params: undefined, + references: ['y-axis-column-layer0X1'], + scale: 'ratio', + }, + 'y-axis-column-layer0X3': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: 'Part of Pages loaded', + operationType: 'math', + params: { + tinymathAst: { + args: ['y-axis-column-layer0X0', 'y-axis-column-layer0X2'], + location: { + max: 288, + min: 0, + }, + name: 'divide', + text: "count(kql='transaction.type: page-load and processor.event: transaction and transaction.type : * and service.name: (elastic or kibana)') / overall_sum(count(kql='transaction.type: page-load and processor.event: transaction and transaction.type : * and service.name: (elastic or kibana)'))", + type: 'function', + }, + }, + references: ['y-axis-column-layer0X0', 'y-axis-column-layer0X2'], + scale: 'ratio', + }, + }, + incompleteColumns: {}, + }, + 'layer0-reference-lines': { + columnOrder: [ + '50th-percentile-reference-line-layer0-reference-lines', + '75th-percentile-reference-line-layer0-reference-lines', + '90th-percentile-reference-line-layer0-reference-lines', + '95th-percentile-reference-line-layer0-reference-lines', + '99th-percentile-reference-line-layer0-reference-lines', + ], + columns: { + '50th-percentile-reference-line-layer0-reference-lines': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: '50th', + operationType: 'percentile', + params: { + percentile: 50, + }, + scale: 'ratio', + sourceField: 'transaction.duration.us', + }, + '75th-percentile-reference-line-layer0-reference-lines': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: '75th', + operationType: 'percentile', + params: { + percentile: 75, + }, + scale: 'ratio', + sourceField: 'transaction.duration.us', + }, + '90th-percentile-reference-line-layer0-reference-lines': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: '90th', + operationType: 'percentile', + params: { + percentile: 90, + }, + scale: 'ratio', + sourceField: 'transaction.duration.us', + }, + '95th-percentile-reference-line-layer0-reference-lines': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: '95th', + operationType: 'percentile', + params: { + percentile: 95, + }, + scale: 'ratio', + sourceField: 'transaction.duration.us', + }, + '99th-percentile-reference-line-layer0-reference-lines': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: '99th', + operationType: 'percentile', + params: { + percentile: 99, + }, + scale: 'ratio', + sourceField: 'transaction.duration.us', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { + language: 'kuery', + query: + 'transaction.type: page-load and processor.event: transaction and transaction.type : * and service.name: (elastic or kibana) and transaction.duration.us < 60000000', + }, + visualization: { + axisTitlesVisibilitySettings: { + x: false, + yLeft: true, + yRight: true, + }, + curveType: 'CURVE_MONOTONE_X', + fittingFunction: 'Linear', + gridlinesVisibilitySettings: { + x: false, + yLeft: true, + yRight: true, + }, + layers: [ + { + accessors: ['y-axis-column-layer0-0'], + layerId: 'layer0', + layerType: 'data', + palette: undefined, + seriesType: 'line', + xAccessor: 'x-axis-column-layer0', + yConfig: [ + { + axisMode: 'left', + color: 'green', + forAccessor: 'y-axis-column-layer0-0', + }, + ], + }, + { + accessors: [ + '50th-percentile-reference-line-layer0-reference-lines', + '75th-percentile-reference-line-layer0-reference-lines', + '90th-percentile-reference-line-layer0-reference-lines', + '95th-percentile-reference-line-layer0-reference-lines', + '99th-percentile-reference-line-layer0-reference-lines', + ], + layerId: 'layer0-reference-lines', + layerType: 'referenceLine', + yConfig: [ + { + axisMode: 'bottom', + color: '#6092C0', + forAccessor: '50th-percentile-reference-line-layer0-reference-lines', + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + }, + { + axisMode: 'bottom', + color: '#6092C0', + forAccessor: '75th-percentile-reference-line-layer0-reference-lines', + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + }, + { + axisMode: 'bottom', + color: '#6092C0', + forAccessor: '90th-percentile-reference-line-layer0-reference-lines', + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + }, + { + axisMode: 'bottom', + color: '#6092C0', + forAccessor: '95th-percentile-reference-line-layer0-reference-lines', + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + }, + { + axisMode: 'bottom', + color: '#6092C0', + forAccessor: '99th-percentile-reference-line-layer0-reference-lines', + lineStyle: 'solid', + lineWidth: 2, + textVisibility: true, + }, + ], + }, + ], + legend: { + isVisible: true, + position: 'right', + showSingleSeries: true, + legendSize: 'auto', + shouldTruncate: false, + }, + preferredSeriesType: 'line', + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + valueLabels: 'hide', + }, + }, + title: 'Prefilled from exploratory view app', + visualizationType: 'lnsXY', +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/test_data_view.json b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/test_data_view.json new file mode 100644 index 0000000000000..75020f5fd30be --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/test_data_view.json @@ -0,0 +1,11 @@ +{ + "attributes": { + "fieldFormatMap": "{\"client.bytes\":{\"id\":\"bytes\"},\"client.nat.port\":{\"id\":\"string\"},\"client.port\":{\"id\":\"string\"},\"destination.bytes\":{\"id\":\"bytes\"},\"destination.nat.port\":{\"id\":\"string\"},\"destination.port\":{\"id\":\"string\"},\"event.duration\":{\"id\":\"duration\",\"params\":{\"inputFormat\":\"nanoseconds\",\"outputFormat\":\"asMilliseconds\",\"outputPrecision\":1}},\"event.sequence\":{\"id\":\"string\"},\"event.severity\":{\"id\":\"string\"},\"http.request.body.bytes\":{\"id\":\"bytes\"},\"http.request.bytes\":{\"id\":\"bytes\"},\"http.response.body.bytes\":{\"id\":\"bytes\"},\"http.response.bytes\":{\"id\":\"bytes\"},\"http.response.status_code\":{\"id\":\"string\"},\"log.syslog.facility.code\":{\"id\":\"string\"},\"log.syslog.priority\":{\"id\":\"string\"},\"network.bytes\":{\"id\":\"bytes\"},\"package.size\":{\"id\":\"string\"},\"process.parent.pgid\":{\"id\":\"string\"},\"process.parent.pid\":{\"id\":\"string\"},\"process.parent.ppid\":{\"id\":\"string\"},\"process.parent.thread.id\":{\"id\":\"string\"},\"process.pgid\":{\"id\":\"string\"},\"process.pid\":{\"id\":\"string\"},\"process.ppid\":{\"id\":\"string\"},\"process.thread.id\":{\"id\":\"string\"},\"server.bytes\":{\"id\":\"bytes\"},\"server.nat.port\":{\"id\":\"string\"},\"server.port\":{\"id\":\"string\"},\"source.bytes\":{\"id\":\"bytes\"},\"source.nat.port\":{\"id\":\"string\"},\"source.port\":{\"id\":\"string\"},\"system.cpu.total.norm.pct\":{\"id\":\"percent\"},\"system.memory.actual.free\":{\"id\":\"bytes\"},\"system.memory.total\":{\"id\":\"bytes\"},\"system.process.cgroup.memory.mem.limit.bytes\":{\"id\":\"bytes\"},\"system.process.cgroup.memory.mem.usage.bytes\":{\"id\":\"bytes\"},\"system.process.cpu.total.norm.pct\":{\"id\":\"percent\"},\"system.process.memory.rss.bytes\":{\"id\":\"bytes\"},\"system.process.memory.size\":{\"id\":\"bytes\"},\"url.port\":{\"id\":\"string\"}}", + "fields": "[{\"name\":\"system.memory.usage\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"metadata_field\":false},{\"name\":\"@timestamp\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.build.original\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"agent.ephemeral_id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"agent.hostname\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"agent.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"agent.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"agent.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"agent.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"as.number\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"as.organization.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"as.organization.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"as.organization.name\"}}},{\"name\":\"child.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.address\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.as.number\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.as.organization.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.as.organization.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"client.as.organization.name\"}}},{\"name\":\"client.bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.geo.city_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.geo.continent_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.geo.country_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.geo.country_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.geo.location\",\"type\":\"geo_point\",\"esTypes\":[\"geo_point\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.geo.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.geo.region_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.geo.region_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.mac\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.nat.ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.nat.port\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.packets\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.port\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.registered_domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.subdomain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.top_level_domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.user.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.user.email\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.user.full_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.user.full_name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"client.user.full_name\"}}},{\"name\":\"client.user.group.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.user.group.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.user.group.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.user.hash\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.user.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.user.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"client.user.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"client.user.name\"}}},{\"name\":\"client.user.roles\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"cloud.account.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"cloud.account.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"cloud.availability_zone\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"cloud.image.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"cloud.instance.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"cloud.instance.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"cloud.machine.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"cloud.project.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"cloud.project.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"cloud.provider\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"cloud.region\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"cloud.service.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clr.gc.count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clr.gc.gen0size\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clr.gc.gen1size\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clr.gc.gen2size\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clr.gc.gen3size\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"code_signature.exists\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"code_signature.status\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"code_signature.subject_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"code_signature.trusted\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"code_signature.valid\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"container.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"container.image.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"container.image.tag\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"container.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"container.runtime\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.address\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.as.number\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.as.organization.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.as.organization.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"destination.as.organization.name\"}}},{\"name\":\"destination.bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.geo.city_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.geo.continent_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.geo.country_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.geo.country_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.geo.location\",\"type\":\"geo_point\",\"esTypes\":[\"geo_point\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.geo.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.geo.region_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.geo.region_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.mac\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.nat.ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.nat.port\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.packets\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.port\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.registered_domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.subdomain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.top_level_domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.user.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.user.email\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.user.full_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.user.full_name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"destination.user.full_name\"}}},{\"name\":\"destination.user.group.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.user.group.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.user.group.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.user.hash\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.user.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.user.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"destination.user.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"destination.user.name\"}}},{\"name\":\"destination.user.roles\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.code_signature.exists\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.code_signature.status\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.code_signature.subject_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.code_signature.trusted\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.code_signature.valid\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.hash.md5\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.hash.sha1\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.hash.sha256\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.hash.sha512\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.path\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.pe.architecture\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.pe.company\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.pe.description\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.pe.file_version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.pe.imphash\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.pe.original_file_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dll.pe.product\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.answers.class\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.answers.data\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.answers.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.answers.ttl\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.answers.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.header_flags\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.op_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.question.class\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.question.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.question.registered_domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.question.subdomain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.question.top_level_domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.question.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.resolved_ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.response_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"dns.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ecs.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"error.code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"error.culprit\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"error.exception.code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"error.exception.handled\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"error.exception.message\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"error.exception.module\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"error.exception.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"error.grouping_key\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"error.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"error.log.level\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"error.log.logger_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"error.log.message\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"error.log.param_message\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"error.message\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"error.stack_trace\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":false,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"error.stack_trace.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"error.stack_trace\"}}},{\"name\":\"error.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.action\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.category\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.created\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.dataset\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.duration\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.end\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.hash\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.ingested\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.kind\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.module\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.original\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":false,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.outcome\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.provider\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.reason\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.reference\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.risk_score\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.risk_score_norm\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.sequence\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.severity\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.start\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.timezone\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event.url\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.accessed\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.attributes\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.code_signature.exists\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.code_signature.status\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.code_signature.subject_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.code_signature.trusted\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.code_signature.valid\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.created\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.ctime\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.device\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.directory\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.drive_letter\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.extension\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.gid\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.group\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.hash.md5\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.hash.sha1\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.hash.sha256\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.hash.sha512\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.inode\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.mime_type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.mode\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.mtime\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.owner\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.path\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.path.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"file.path\"}}},{\"name\":\"file.pe.architecture\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.pe.company\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.pe.description\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.pe.file_version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.pe.imphash\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.pe.original_file_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.pe.product\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.size\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.target_path\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.target_path.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"file.target_path\"}}},{\"name\":\"file.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.uid\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.alternative_names\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.issuer.common_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.issuer.country\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.issuer.distinguished_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.issuer.locality\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.issuer.organization\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.issuer.organizational_unit\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.issuer.state_or_province\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.not_after\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.not_before\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.public_key_algorithm\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.public_key_curve\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.public_key_exponent\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":false,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.public_key_size\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.serial_number\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.signature_algorithm\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.subject.common_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.subject.country\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.subject.distinguished_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.subject.locality\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.subject.organization\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.subject.organizational_unit\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.subject.state_or_province\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"file.x509.version_number\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.city_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.continent_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.country_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.country_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.location\",\"type\":\"geo_point\",\"esTypes\":[\"geo_point\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.region_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.region_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.goroutines\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.heap.allocations.active\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.heap.allocations.allocated\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.heap.allocations.frees\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.heap.allocations.idle\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.heap.allocations.mallocs\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.heap.allocations.objects\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.heap.allocations.total\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.heap.gc.cpu_fraction\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.heap.gc.next_gc_limit\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.heap.gc.total_count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.heap.gc.total_pause.ns\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.heap.system.obtained\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.heap.system.released\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.heap.system.stack\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"golang.heap.system.total\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"group.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"group.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"group.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"hash.md5\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"hash.sha1\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"hash.sha256\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"hash.sha512\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.architecture\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.containerized\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.geo.city_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.geo.continent_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.geo.country_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.geo.country_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.geo.location\",\"type\":\"geo_point\",\"esTypes\":[\"geo_point\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.geo.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.geo.region_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.geo.region_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.hostname\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.mac\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.os.build\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.os.codename\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.os.family\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.os.full\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.os.full.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"host.os.full\"}}},{\"name\":\"host.os.kernel\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.os.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.os.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"host.os.name\"}}},{\"name\":\"host.os.platform\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.os.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.uptime\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.user.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.user.email\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.user.full_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.user.full_name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"host.user.full_name\"}}},{\"name\":\"host.user.group.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.user.group.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.user.group.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.user.hash\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.user.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.user.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host.user.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"host.user.name\"}}},{\"name\":\"host.user.roles\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"http.request.body.bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"http.request.body.content\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"http.request.body.content.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"http.request.body.content\"}}},{\"name\":\"http.request.bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"http.request.method\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"http.request.mime_type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"http.request.referrer\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"http.response.body.bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"http.response.body.content\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"http.response.body.content.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"http.response.body.content\"}}},{\"name\":\"http.response.bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"http.response.finished\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"http.response.mime_type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"http.response.status_code\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"http.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"interface.alias\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"interface.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"interface.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"jvm.gc.alloc\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"jvm.gc.count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"jvm.gc.time\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"jvm.memory.heap.committed\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"jvm.memory.heap.max\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"jvm.memory.heap.pool.committed\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"jvm.memory.heap.pool.max\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"jvm.memory.heap.pool.used\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"jvm.memory.heap.used\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"jvm.memory.non_heap.committed\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"jvm.memory.non_heap.max\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"jvm.memory.non_heap.used\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"jvm.thread.count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"kubernetes.container.image\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"kubernetes.container.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"kubernetes.deployment.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"kubernetes.namespace\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"kubernetes.node.hostname\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"kubernetes.node.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"kubernetes.pod.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"kubernetes.pod.uid\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"kubernetes.replicaset.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"kubernetes.statefulset.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.city\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.company\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.country_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.customer_email\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.customer_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.customer_tier\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.env\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.events_encoded\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.events_failed\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.events_original\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.events_published\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.foo\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.git_rev\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.hostname\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.in_eu\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.ip\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.kibana_uuid\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.lang\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.lorem\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.multi-line\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.plugin\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.productId\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.request_id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.served_from_cache\",\"type\":\"conflict\",\"esTypes\":[\"boolean\",\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false,\"conflictDescriptions\":{\"boolean\":[\"apm-8.0.0-transaction-000001\"],\"keyword\":[\"apm-8.0.0-transaction-000002\"]}},{\"name\":\"labels.taskType\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.this-is-a-very-long-tag-name-without-any-spaces\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.u\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"labels.worker\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"log.file.path\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"log.level\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"log.logger\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"log.origin.file.line\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"log.origin.file.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"log.origin.function\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"log.original\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":false,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"log.syslog.facility.code\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"log.syslog.facility.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"log.syslog.priority\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"log.syslog.severity.code\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"log.syslog.severity.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"message\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"metricset.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"metricset.period\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"network.application\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"network.bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"network.community_id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"network.direction\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"network.forwarded_ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"network.iana_number\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"network.inner.vlan.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"network.inner.vlan.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"network.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"network.packets\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"network.protocol\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"network.transport\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"network.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"network.vlan.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"network.vlan.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"nodejs.eventloop.delay.avg.ms\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"nodejs.eventloop.delay.ns\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"nodejs.handles.active\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"nodejs.memory.arrayBuffers.bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"nodejs.memory.external.bytes\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"nodejs.memory.heap.allocated.bytes\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"nodejs.memory.heap.used.bytes\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"nodejs.requests.active\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.egress.interface.alias\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.egress.interface.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.egress.interface.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.egress.vlan.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.egress.vlan.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.egress.zone\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.geo.city_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.geo.continent_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.geo.country_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.geo.country_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.geo.location\",\"type\":\"geo_point\",\"esTypes\":[\"geo_point\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.geo.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.geo.region_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.geo.region_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.hostname\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.ingress.interface.alias\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.ingress.interface.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.ingress.interface.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.ingress.vlan.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.ingress.vlan.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.ingress.zone\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.listening\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.mac\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.os.family\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.os.full\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.os.full.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"observer.os.full\"}}},{\"name\":\"observer.os.kernel\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.os.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.os.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"observer.os.name\"}}},{\"name\":\"observer.os.platform\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.os.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.product\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.serial_number\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.vendor\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"observer.version_major\",\"type\":\"number\",\"esTypes\":[\"byte\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"organization.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"organization.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"organization.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"organization.name\"}}},{\"name\":\"os.family\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"os.full\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"os.full.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"os.full\"}}},{\"name\":\"os.kernel\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"os.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"os.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"os.name\"}}},{\"name\":\"os.platform\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"os.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"package.architecture\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"package.build_version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"package.checksum\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"package.description\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"package.install_scope\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"package.installed\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"package.license\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"package.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"package.path\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"package.reference\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"package.size\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"package.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"package.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"parent.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"pe.architecture\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"pe.company\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"pe.description\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"pe.file_version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"pe.imphash\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"pe.original_file_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"pe.product\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.args\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.args_count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.code_signature.exists\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.code_signature.status\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.code_signature.subject_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.code_signature.trusted\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.code_signature.valid\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.command_line\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.command_line.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"process.command_line\"}}},{\"name\":\"process.entity_id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.executable\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.executable.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"process.executable\"}}},{\"name\":\"process.exit_code\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.hash.md5\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.hash.sha1\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.hash.sha256\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.hash.sha512\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"process.name\"}}},{\"name\":\"process.parent.args\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.args_count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.code_signature.exists\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.code_signature.status\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.code_signature.subject_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.code_signature.trusted\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.code_signature.valid\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.command_line\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.command_line.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"process.parent.command_line\"}}},{\"name\":\"process.parent.entity_id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.executable\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.executable.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"process.parent.executable\"}}},{\"name\":\"process.parent.exit_code\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.hash.md5\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.hash.sha1\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.hash.sha256\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.hash.sha512\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"process.parent.name\"}}},{\"name\":\"process.parent.pe.architecture\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.pe.company\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.pe.description\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.pe.file_version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.pe.imphash\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.pe.original_file_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.pe.product\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.pgid\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.pid\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.ppid\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.start\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.thread.id\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.thread.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.title\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.title.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"process.parent.title\"}}},{\"name\":\"process.parent.uptime\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.working_directory\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.parent.working_directory.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"process.parent.working_directory\"}}},{\"name\":\"process.pe.architecture\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.pe.company\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.pe.description\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.pe.file_version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.pe.imphash\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.pe.original_file_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.pe.product\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.pgid\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.pid\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.ppid\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.start\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.thread.id\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.thread.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.title\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.uptime\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.working_directory\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"process.working_directory.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"process.working_directory\"}}},{\"name\":\"processor.event\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"processor.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.alloc_objects.count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.alloc_space.bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.cpu.ns\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.duration\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.inuse_objects.count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.inuse_space.bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.samples.count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.stack.filename\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.stack.function\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.stack.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.stack.line\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.top.filename\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.top.function\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.top.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"profile.top.line\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"registry.data.bytes\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"registry.data.strings\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"registry.data.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"registry.hive\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"registry.key\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"registry.path\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"registry.value\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"related.hash\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"related.hosts\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"related.ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"related.user\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ruby.gc.count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ruby.heap.allocations.total\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ruby.heap.slots.free\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ruby.heap.slots.live\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ruby.threads\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"rule.author\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"rule.category\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"rule.description\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"rule.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"rule.license\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"rule.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"rule.reference\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"rule.ruleset\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"rule.uuid\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"rule.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.address\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.as.number\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.as.organization.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.as.organization.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"server.as.organization.name\"}}},{\"name\":\"server.bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.geo.city_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.geo.continent_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.geo.country_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.geo.country_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.geo.location\",\"type\":\"geo_point\",\"esTypes\":[\"geo_point\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.geo.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.geo.region_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.geo.region_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.mac\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.nat.ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.nat.port\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.packets\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.port\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.registered_domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.subdomain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.top_level_domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.user.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.user.email\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.user.full_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.user.full_name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"server.user.full_name\"}}},{\"name\":\"server.user.group.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.user.group.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.user.group.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.user.hash\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.user.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.user.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"server.user.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"server.user.name\"}}},{\"name\":\"server.user.roles\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"service.environment\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"service.ephemeral_id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"service.framework.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"service.framework.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"service.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"service.language.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"service.language.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"service.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"service.node.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"service.runtime.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"service.runtime.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"service.state\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"service.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"service.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.address\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.as.number\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.as.organization.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.as.organization.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"source.as.organization.name\"}}},{\"name\":\"source.bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.geo.city_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.geo.continent_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.geo.country_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.geo.country_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.geo.location\",\"type\":\"geo_point\",\"esTypes\":[\"geo_point\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.geo.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.geo.region_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.geo.region_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.mac\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.nat.ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.nat.port\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.packets\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.port\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.registered_domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.subdomain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.top_level_domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.user.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.user.email\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.user.full_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.user.full_name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"source.user.full_name\"}}},{\"name\":\"source.user.group.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.user.group.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.user.group.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.user.hash\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.user.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.user.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"source.user.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"source.user.name\"}}},{\"name\":\"source.user.roles\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"sourcemap.bundle_filepath\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"sourcemap.service.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"sourcemap.service.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.action\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.db.link\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.db.rows_affected\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.destination.service.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.destination.service.resource\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.destination.service.response_time.count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.destination.service.response_time.sum.us\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.destination.service.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.duration.us\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.message.age.ms\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.message.queue.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.self_time.count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.self_time.sum.us\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.start.us\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.subtype\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.sync\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"span.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"system.cpu.total.norm.pct\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"system.memory.actual.free\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"system.memory.total\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"system.process.cgroup.memory.mem.limit.bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"system.process.cgroup.memory.mem.usage.bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"system.process.cgroup.memory.stats.inactive_file.bytes\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"system.process.cpu.system.norm.pct\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"system.process.cpu.total.norm.pct\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"system.process.cpu.user.norm.pct\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"system.process.memory.rss.bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"system.process.memory.size\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tags\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"threat.framework\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"threat.tactic.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"threat.tactic.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"threat.tactic.reference\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"threat.technique.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"threat.technique.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"threat.technique.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"threat.technique.name\"}}},{\"name\":\"threat.technique.reference\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"threat.technique.subtechnique.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"threat.technique.subtechnique.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"threat.technique.subtechnique.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"threat.technique.subtechnique.name\"}}},{\"name\":\"threat.technique.subtechnique.reference\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"timeseries.instance\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"timestamp.us\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.cipher\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.certificate\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.certificate_chain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.hash.md5\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.hash.sha1\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.hash.sha256\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.issuer\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.ja3\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.not_after\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.not_before\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.server_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.subject\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.supported_ciphers\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.alternative_names\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.issuer.common_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.issuer.country\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.issuer.distinguished_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.issuer.locality\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.issuer.organization\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.issuer.organizational_unit\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.issuer.state_or_province\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.not_after\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.not_before\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.public_key_algorithm\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.public_key_curve\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.public_key_exponent\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":false,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.public_key_size\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.serial_number\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.signature_algorithm\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.subject.common_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.subject.country\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.subject.distinguished_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.subject.locality\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.subject.organization\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.subject.organizational_unit\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.subject.state_or_province\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.client.x509.version_number\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.curve\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.established\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.next_protocol\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.resumed\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.certificate\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.certificate_chain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.hash.md5\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.hash.sha1\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.hash.sha256\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.issuer\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.ja3s\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.not_after\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.not_before\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.subject\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.alternative_names\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.issuer.common_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.issuer.country\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.issuer.distinguished_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.issuer.locality\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.issuer.organization\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.issuer.organizational_unit\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.issuer.state_or_province\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.not_after\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.not_before\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.public_key_algorithm\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.public_key_curve\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.public_key_exponent\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":false,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.public_key_size\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.serial_number\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.signature_algorithm\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.subject.common_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.subject.country\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.subject.distinguished_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.subject.locality\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.subject.organization\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.subject.organizational_unit\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.subject.state_or_province\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.server.x509.version_number\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tls.version_protocol\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"trace.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.breakdown.count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.duration.count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.duration.histogram\",\"type\":\"histogram\",\"esTypes\":[\"histogram\"],\"searchable\":false,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.duration.sum.us\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.duration.us\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.experience.cls\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.experience.fid\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.experience.longtask.count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.experience.longtask.max\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.experience.longtask.sum\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.experience.tbt\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.agent.domComplete\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.agent.domInteractive\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.agent.firstContentfulPaint\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.agent.largestContentfulPaint\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.agent.timeToFirstByte\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.navigationTiming.connectEnd\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.navigationTiming.connectStart\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.navigationTiming.domComplete\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.navigationTiming.domContentLoadedEventEnd\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.navigationTiming.domContentLoadedEventStart\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.navigationTiming.domInteractive\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.navigationTiming.domLoading\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.navigationTiming.domainLookupEnd\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.navigationTiming.domainLookupStart\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.navigationTiming.fetchStart\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.navigationTiming.loadEventEnd\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.navigationTiming.loadEventStart\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.navigationTiming.requestStart\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.navigationTiming.responseEnd\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.marks.navigationTiming.responseStart\",\"type\":\"number\",\"esTypes\":[\"scaled_float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.message.age.ms\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.message.queue.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"transaction.name\"}}},{\"name\":\"transaction.result\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.root\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.sampled\",\"type\":\"boolean\",\"esTypes\":[\"boolean\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.self_time.count\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.self_time.sum.us\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.span_count.dropped\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"transaction.type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url.extension\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url.fragment\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url.full\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url.original\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url.original.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"url.original\"}}},{\"name\":\"url.password\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url.path\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url.port\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url.query\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url.registered_domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url.scheme\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url.subdomain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url.top_level_domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url.username\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user.email\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user.full_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user.full_name.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"user.full_name\"}}},{\"name\":\"user.group.domain\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user.group.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user.group.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user.hash\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user.roles\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user_agent.device.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user_agent.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user_agent.original\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user_agent.original.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"user_agent.original\"}}},{\"name\":\"user_agent.os.family\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user_agent.os.full\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user_agent.os.kernel\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user_agent.os.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user_agent.os.platform\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user_agent.os.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user_agent.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"vlan.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"vlan.name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"vulnerability.category\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"vulnerability.classification\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"vulnerability.description\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"vulnerability.description.text\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false,\"subType\":{\"multi\":{\"parent\":\"vulnerability.description\"}}},{\"name\":\"vulnerability.enumeration\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"vulnerability.id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"vulnerability.reference\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"vulnerability.report_id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"vulnerability.scanner.vendor\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"vulnerability.score.base\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"vulnerability.score.environmental\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"vulnerability.score.temporal\",\"type\":\"number\",\"esTypes\":[\"float\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"vulnerability.score.version\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"vulnerability.severity\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.alternative_names\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.issuer.common_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.issuer.country\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.issuer.distinguished_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.issuer.locality\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.issuer.organization\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.issuer.organizational_unit\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.issuer.state_or_province\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.not_after\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.not_before\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.public_key_algorithm\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.public_key_curve\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.public_key_exponent\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":false,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.public_key_size\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.serial_number\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.signature_algorithm\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.subject.common_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.subject.country\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.subject.distinguished_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.subject.locality\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.subject.organization\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.subject.organizational_unit\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.subject.state_or_province\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"x509.version_number\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", + "sourceFilters": "[{\"value\":\"sourcemap.sourcemap\"}]", + "timeFieldName": "@timestamp" + }, + "id": "apm-*", + "type": "index-pattern", + "version": "1" +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/test_formula_metric_attribute.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/test_formula_metric_attribute.ts new file mode 100644 index 0000000000000..d1f63100ecbe8 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/test_data/test_formula_metric_attribute.ts @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockDataView } from '../../rtl_helpers'; + +export const sampleMetricFormulaAttribute = { + description: '', + references: [], + state: { + adHocDataViews: { [mockDataView.title]: mockDataView.toSpec(false) }, + internalReferences: [ + { + id: 'apm-*', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'apm-*', + name: 'indexpattern-datasource-layer-layer0', + type: 'index-pattern', + }, + ], + datasourceStates: { + formBased: { + layers: { + layer0: { + columnOrder: [ + 'layer-0-column-1X0', + 'layer-0-column-1X1', + 'layer-0-column-1X2', + 'layer-0-column-1', + ], + columns: { + 'layer-0-column-1': { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: 'summary.up: *', + }, + isBucketed: false, + label: 'Availability', + operationType: 'formula', + params: { + format: { + id: 'percent', + params: { + decimals: 1, + }, + }, + formula: "1- (count(kql='summary.down > 0') / count())", + isFormulaBroken: false, + }, + references: ['layer-0-column-1X2'], + }, + 'layer-0-column-1X0': { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: '(summary.up: *) AND (summary.down > 0)', + }, + isBucketed: false, + label: 'Part of Availability', + operationType: 'count', + params: { + emptyAsNull: false, + }, + scale: 'ratio', + sourceField: '___records___', + }, + 'layer-0-column-1X1': { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: 'summary.up: *', + }, + isBucketed: false, + label: 'Part of Availability', + operationType: 'count', + params: { + emptyAsNull: false, + }, + scale: 'ratio', + sourceField: '___records___', + }, + 'layer-0-column-1X2': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: 'Part of Availability', + operationType: 'math', + params: { + tinymathAst: { + args: [ + 1, + { + args: ['layer-0-column-1X0', 'layer-0-column-1X1'], + location: { + max: 44, + min: 2, + }, + name: 'divide', + text: " (count(kql='summary.down > 0') / count())", + type: 'function', + }, + ], + location: { + max: 44, + min: 0, + }, + name: 'subtract', + text: "1- (count(kql='summary.down > 0') / count())", + type: 'function', + }, + }, + references: ['layer-0-column-1X0', 'layer-0-column-1X1'], + scale: 'ratio', + }, + }, + indexPatternId: 'apm-*', + }, + }, + }, + }, + filters: [], + query: { + language: 'kuery', + query: '', + }, + visualization: { + accessor: 'layer-0-column-1', + colorMode: 'Labels', + layerId: 'layer0', + layerType: 'data', + palette: { + name: 'custom', + params: { + colorStops: [ + { + color: '#cc5642', + stop: 0.8, + }, + { + color: '#d6bf57', + stop: 0.9, + }, + { + color: '#209280', + stop: 0.95, + }, + ], + continuity: 'above', + maxSteps: 5, + name: 'custom', + progression: 'fixed', + rangeMax: 1, + rangeMin: 0, + rangeType: 'number', + reverse: false, + steps: 3, + stops: [ + { + color: '#cc5642', + stop: 0.9, + }, + { + color: '#d6bf57', + stop: 0.95, + }, + { + color: '#209280', + stop: 1.9903347477604902, + }, + ], + }, + type: 'palette', + }, + size: 's', + titlePosition: 'bottom', + }, + }, + title: 'Prefilled from exploratory view app', + visualizationType: 'lnsLegacyMetric', +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/utils.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/utils.ts new file mode 100644 index 0000000000000..c8848e50f917f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/configurations/utils.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import rison from '@kbn/rison'; +import { + buildQueryFilter, + PhraseFilter, + ExistsFilter, + buildPhraseFilter as esBuildPhraseFilter, + buildPhrasesFilter as esBuildPhrasesFilter, + buildExistsFilter as esBuildExistsFilter, +} from '@kbn/es-query'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { PersistableFilter } from '@kbn/lens-plugin/common'; +import type { ReportViewType, UrlFilter } from '../types'; +import type { AllSeries, AllShortSeries } from '../hooks/use_series_storage'; +import { convertToShortUrl, encodeUriIfNeeded } from './exploratory_view_url'; + +export function createExploratoryViewRoutePath({ + reportType, + allSeries, +}: { + reportType: ReportViewType; + allSeries: AllSeries; +}) { + const allShortSeries: AllShortSeries = allSeries.map((series) => convertToShortUrl(series)); + + return `/exploratory-view/#?reportType=${reportType}&sr=${encodeUriIfNeeded( + rison.encode(allShortSeries) + )}`; +} + +export function buildPhraseFilter(field: string, value: string, dataView?: DataView) { + const fieldMeta = dataView?.fields.find((fieldT) => fieldT.name === field); + if (fieldMeta && dataView) { + return [esBuildPhraseFilter(fieldMeta, value, dataView)]; + } + return []; +} + +export function getQueryFilter(field: string, value: string[], dataView?: DataView) { + const fieldMeta = dataView?.fields.find((fieldT) => fieldT.name === field); + if (fieldMeta && dataView) { + return value.map((val) => + buildQueryFilter( + { + query_string: { + fields: [field], + query: `*${val}*`, + }, + }, + dataView.id!, + '' + ) + ); + } + + return []; +} + +export function buildPhrasesFilter( + field: string, + value: Array, + dataView?: DataView +) { + const fieldMeta = dataView?.fields.find((fieldT) => fieldT.name === field); + if (fieldMeta && dataView) { + if (value.length === 1) { + return [esBuildPhraseFilter(fieldMeta, value[0], dataView)]; + } + return [esBuildPhrasesFilter(fieldMeta, value, dataView)]; + } + return []; +} + +export function buildExistsFilter(field: string, dataView?: DataView) { + const fieldMeta = dataView?.fields.find((fieldT) => fieldT.name === field); + if (fieldMeta && dataView) { + return [esBuildExistsFilter(fieldMeta, dataView)]; + } + return []; +} + +type FiltersType = Array; + +export function urlFilterToPersistedFilter({ + urlFilters, + initFilters, + dataView, +}: { + urlFilters: UrlFilter[]; + initFilters?: FiltersType; + dataView: DataView; +}) { + const parsedFilters: FiltersType = initFilters ? [...initFilters] : []; + + urlFilters.forEach( + ({ field, values = [], notValues = [], wildcards = [], notWildcards = ([] = []) }) => { + if (values.length > 0) { + const filter = buildPhrasesFilter(field, values, dataView); + parsedFilters.push(...filter); + } + + if (notValues.length > 0) { + const filter = buildPhrasesFilter(field, notValues, dataView)[0]; + filter.meta.negate = true; + parsedFilters.push(filter); + } + + if (wildcards.length > 0) { + const filter = getQueryFilter(field, wildcards, dataView); + parsedFilters.push(...filter); + } + + if (notWildcards.length > 0) { + const filter = getQueryFilter(field, notWildcards, dataView)[0]; + filter.meta.negate = true; + parsedFilters.push(filter); + } + } + ); + + return parsedFilters; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/contexts/exploratory_view_config.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/contexts/exploratory_view_config.tsx new file mode 100644 index 0000000000000..08df78cd072a9 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/contexts/exploratory_view_config.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, useContext, useState } from 'react'; +import { AppMountParameters } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import type { AppDataType, ConfigProps, ReportViewType, SeriesConfig } from '../types'; + +export type ReportConfigMap = Record SeriesConfig>>; + +interface ExploratoryViewContextValue { + dataTypes: Array<{ id: AppDataType; label: string }>; + reportTypes: Array<{ + reportType: ReportViewType | typeof SELECT_REPORT_TYPE; + label: string; + }>; + reportConfigMap: ReportConfigMap; + asPanel?: boolean; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; + theme$: AppMountParameters['theme$']; + isEditMode?: boolean; + setIsEditMode?: React.Dispatch>; +} + +export const ExploratoryViewContext = createContext( + {} as ExploratoryViewContextValue +); + +export function ExploratoryViewContextProvider({ + children, + reportTypes, + dataTypes, + reportConfigMap, + setHeaderActionMenu, + asPanel = true, + theme$, +}: { children: JSX.Element } & ExploratoryViewContextValue) { + const [isEditMode, setIsEditMode] = useState(false); + + const value = { + asPanel, + reportTypes, + dataTypes, + reportConfigMap, + setHeaderActionMenu, + theme$, + isEditMode, + setIsEditMode, + }; + + return ( + {children} + ); +} + +export function useExploratoryView() { + const context = useContext(ExploratoryViewContext); + + if (context === undefined) { + throw new Error('useExploratoryView must be used within a ExploratoryViewContextProvider'); + } + return context; +} + +export const SELECT_REPORT_TYPE = i18n.translate( + 'xpack.exploratoryView.expView.seriesBuilder.selectReportType', + { + defaultMessage: 'No report type selected', + } +); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/embeddable.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/embeddable.test.tsx new file mode 100644 index 0000000000000..f10d188c6b566 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/embeddable.test.tsx @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import Embeddable from './embeddable'; +import { LensPublicStart } from '@kbn/lens-plugin/public'; +import { DataViewState } from '../hooks/use_app_data_view'; +import { render } from '../rtl_helpers'; +import { AddToCaseAction } from '../header/add_to_case_action'; +import { ActionTypes } from './use_actions'; + +jest.mock('../header/add_to_case_action', () => ({ + AddToCaseAction: jest.fn(() =>
mockAddToCaseAction
), +})); + +const mockLensAttrs = { + hidePanelTitles: true, + description: '', + visualizationType: 'lnsMetric', + state: { + visualization: { + accessor: 'b00c65ea-32be-4163-bfc8-f795b1ef9d06', + layerId: '416b6fad-1923-4f6a-a2df-b223bb287e30', + layerType: 'data', + }, + query: { + language: 'kuery', + query: '', + }, + filters: [], + datasourceStates: { + formBased: { + layers: { + '416b6fad-1923-4f6a-a2df-b223bb287e30': { + columnOrder: ['b00c65ea-32be-4163-bfc8-f795b1ef9d06'], + columns: { + 'b00c65ea-32be-4163-bfc8-f795b1ef9d06': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: ' ', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'host.name', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + }, + references: [ + { + type: 'index-pattern', + id: 'security-solution-default', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: 'security-solution-default', + name: 'indexpattern-datasource-layer-416b6fad-1923-4f6a-a2df-b223bb287e30', + }, + { + type: 'tag', + id: 'security-solution-default', + name: 'tag-ref-security-solution-default', + }, + ], +}; +const mockTimeRange = { + from: '2022-02-15T16:00:00.000Z', + to: '2022-02-16T15:59:59.999Z', +}; +const mockOwner = 'securitySolution'; +const mockAppId = 'securitySolutionUI'; +const mockDataViews = {} as DataViewState; +const mockReportType = 'kpi-over-time'; +const mockTitle = 'mockTitle'; +const mockLens = { + EmbeddableComponent: jest.fn((props) => { + return ( +
+ mockEmbeddableComponent +
+ ); + }), + SaveModalComponent: jest.fn(() =>
mockSaveModalComponent
), +} as unknown as LensPublicStart; +const mockActions: ActionTypes[] = ['addToCase', 'openInLens']; + +describe('Embeddable', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders title', async () => { + const { container, getByText } = render( + + ); + expect(container.querySelector(`[data-test-subj="exploratoryView-title"]`)).toBeInTheDocument(); + expect(getByText(mockTitle)).toBeInTheDocument(); + }); + + it('renders no title if it is not given', async () => { + const { container } = render( + + ); + expect( + container.querySelector(`[data-test-subj="exploratoryView-title"]`) + ).not.toBeInTheDocument(); + }); + + it('renders lens component', () => { + const { container } = render( + + ); + + expect( + container.querySelector(`[data-test-subj="exploratoryView-singleMetric"]`) + ).not.toBeInTheDocument(); + expect(container.querySelector(`[data-test-subj="exploratoryView"]`)).toBeInTheDocument(); + expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].id).toEqual( + 'exploratoryView' + ); + expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].attributes).toEqual( + mockLensAttrs + ); + expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].timeRange).toEqual( + mockTimeRange + ); + expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].timeRange).toEqual( + mockTimeRange + ); + expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].withDefaultActions).toEqual( + true + ); + }); + + it('renders AddToCaseAction', () => { + render( + + ); + + expect((AddToCaseAction as jest.Mock).mock.calls[0][0].timeRange).toEqual(mockTimeRange); + expect((AddToCaseAction as jest.Mock).mock.calls[0][0].appId).toEqual(mockAppId); + expect((AddToCaseAction as jest.Mock).mock.calls[0][0].lensAttributes).toEqual(mockLensAttrs); + expect((AddToCaseAction as jest.Mock).mock.calls[0][0].owner).toEqual(mockOwner); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/embeddable.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/embeddable.tsx new file mode 100644 index 0000000000000..864e95affed61 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/embeddable.tsx @@ -0,0 +1,318 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Position } from '@elastic/charts'; +import React, { useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui'; +import { + FormulaPublicApi, + LensEmbeddableInput, + LensPublicStart, + XYState, +} from '@kbn/lens-plugin/public'; +import { ViewMode } from '@kbn/embeddable-plugin/common'; +import { observabilityFeatureId } from '@kbn/observability-plugin/public'; +import styled from 'styled-components'; +import { useKibanaSpace } from '../../../../hooks/use_kibana_space'; +import { HeatMapLensAttributes } from '../configurations/lens_attributes/heatmap_attributes'; +import { SingleMetricLensAttributes } from '../configurations/lens_attributes/single_metric_attributes'; +import { AllSeries, ReportTypes } from '../../../..'; +import { useTheme } from '../../../../hooks/use_theme'; +import { LayerConfig, LensAttributes } from '../configurations/lens_attributes'; +import { AppDataType, ReportViewType } from '../types'; +import { getLayerConfigs } from '../hooks/use_lens_attributes'; +import { OperationTypeComponent } from '../series_editor/columns/operation_type_select'; +import { DataViewState } from '../hooks/use_app_data_view'; +import { ReportConfigMap } from '../contexts/exploratory_view_config'; +import { obsvReportConfigMap } from '../obsv_exploratory_view'; +import { ActionTypes, useActions } from './use_actions'; +import { AddToCaseAction } from '../header/add_to_case_action'; + +export interface ExploratoryEmbeddableProps { + id?: string; + appId?: 'securitySolutionUI' | 'observability'; + appendTitle?: JSX.Element; + attributes?: AllSeries; + axisTitlesVisibility?: XYState['axisTitlesVisibilitySettings']; + gridlinesVisibilitySettings?: XYState['gridlinesVisibilitySettings']; + customHeight?: string; + customLensAttrs?: any; // Takes LensAttributes + customTimeRange?: { from: string; to: string }; // required if rendered with LensAttributes + dataTypesIndexPatterns?: Partial>; + isSingleMetric?: boolean; + legendIsVisible?: boolean; + legendPosition?: Position; + hideTicks?: boolean; + onBrushEnd?: (param: { range: number[] }) => void; + onLoad?: (loading: boolean) => void; + caseOwner?: string; + reportConfigMap?: ReportConfigMap; + reportType: ReportViewType; + showCalculationMethod?: boolean; + title?: string | JSX.Element; + withActions?: boolean | ActionTypes[]; + align?: 'left' | 'right' | 'center'; + sparklineMode?: boolean; + noLabel?: boolean; + fontSize?: number; + lineHeight?: number; + dataTestSubj?: string; + searchSessionId?: string; +} + +export interface ExploratoryEmbeddableComponentProps extends ExploratoryEmbeddableProps { + lens: LensPublicStart; + dataViewState: DataViewState; + lensFormulaHelper?: FormulaPublicApi; +} + +// eslint-disable-next-line import/no-default-export +export default function Embeddable({ + appId, + appendTitle, + attributes = [], + axisTitlesVisibility, + gridlinesVisibilitySettings, + customHeight, + customLensAttrs, + customTimeRange, + dataViewState, + legendIsVisible, + legendPosition, + lens, + onBrushEnd, + caseOwner = observabilityFeatureId, + reportConfigMap = {}, + reportType, + showCalculationMethod = false, + title, + withActions = true, + lensFormulaHelper, + hideTicks, + align, + noLabel, + fontSize = 27, + lineHeight = 32, + searchSessionId, + onLoad, +}: ExploratoryEmbeddableComponentProps) { + const LensComponent = lens?.EmbeddableComponent; + const LensSaveModalComponent = lens?.SaveModalComponent; + + const [isSaveOpen, setIsSaveOpen] = useState(false); + const [isAddToCaseOpen, setAddToCaseOpen] = useState(false); + + const spaceId = useKibanaSpace(); + + const series = Object.entries(attributes)[0]?.[1]; + + const [operationType, setOperationType] = useState(series?.operationType); + const theme = useTheme(); + + const layerConfigs: LayerConfig[] = getLayerConfigs( + attributes, + reportType, + theme, + dataViewState, + { ...reportConfigMap, ...obsvReportConfigMap }, + spaceId.space?.id + ); + + let lensAttributes; + let attributesJSON = customLensAttrs; + if (!customLensAttrs) { + try { + if (reportType === ReportTypes.SINGLE_METRIC) { + lensAttributes = new SingleMetricLensAttributes( + layerConfigs, + reportType, + lensFormulaHelper! + ); + attributesJSON = lensAttributes?.getJSON('lnsLegacyMetric'); + } else if (reportType === ReportTypes.HEATMAP) { + lensAttributes = new HeatMapLensAttributes(layerConfigs, reportType, lensFormulaHelper!); + attributesJSON = lensAttributes?.getJSON('lnsHeatmap'); + } else { + lensAttributes = new LensAttributes(layerConfigs, reportType, lensFormulaHelper); + attributesJSON = lensAttributes?.getJSON(); + } + // eslint-disable-next-line no-empty + } catch (error) {} + } + + const timeRange = customTimeRange ?? series?.time; + + const actions = useActions({ + withActions, + attributes, + reportType, + appId, + setIsSaveOpen, + setAddToCaseOpen, + lensAttributes: attributesJSON, + timeRange, + }); + + if (!attributesJSON) { + return null; + } + + if (typeof axisTitlesVisibility !== 'undefined') { + (attributesJSON.state.visualization as XYState).axisTitlesVisibilitySettings = + axisTitlesVisibility; + } + + if (typeof gridlinesVisibilitySettings !== 'undefined') { + (attributesJSON.state.visualization as XYState).gridlinesVisibilitySettings = + gridlinesVisibilitySettings; + } + + if (typeof legendIsVisible !== 'undefined') { + (attributesJSON.state.visualization as XYState).legend.isVisible = legendIsVisible; + } + if (typeof legendPosition !== 'undefined') { + (attributesJSON.state.visualization as XYState).legend.position = legendPosition; + } + + if (hideTicks) { + (attributesJSON.state.visualization as XYState).tickLabelsVisibilitySettings = { + x: false, + yRight: false, + yLeft: false, + }; + } + + if (!attributesJSON && layerConfigs.length < 1) { + return null; + } + + if (!LensComponent) { + return No lens component; + } + + attributesJSON.state.searchSessionId = searchSessionId; + attributesJSON.searchSessionId = searchSessionId; + + return ( + + {(title || showCalculationMethod || appendTitle) && ( + + {title && ( + + +

{title}

+
+
+ )} + {showCalculationMethod && ( + + { + setOperationType(val); + }} + /> + + )} + {appendTitle && appendTitle} +
+ )} + + + {isSaveOpen && attributesJSON && ( + setIsSaveOpen(false)} + // if we want to do anything after the viz is saved + // right now there is no action, so an empty function + onSave={() => {}} + /> + )} + +
+ ); +} + +const Wrapper = styled.div<{ + $customHeight?: string | number; + align?: 'left' | 'right' | 'center'; + noLabel?: boolean; + fontSize?: number; + lineHeight?: number; +}>` + height: ${(props) => (props.$customHeight ? `${props.$customHeight};` : `100%;`)}; + position: relative; + &&& { + > :nth-child(2) { + height: ${(props) => + props.$customHeight ? `${props.$customHeight};` : `calc(100% - 32px);`}; + } + .expExpressionRenderer__expression { + padding: 0 !important; + } + + .legacyMtrVis { + > :first-child { + justify-content: ${(props) => + props.align === 'left' + ? `flex-start;` + : props.align === 'right' + ? `flex-end;` + : 'center;'}; + } + justify-content: flex-end; + .legacyMtrVis__container { + padding: 0; + > :nth-child(2) { + ${({ noLabel }) => + noLabel && + ` display: none; + `} + } + } + .legacyMtrVis__value { + line-height: ${({ lineHeight }) => lineHeight}px !important; + font-size: ${({ fontSize }) => fontSize}px !important; + } + > :first-child { + transform: none !important; + } + } + + .euiLoadingChart { + position: absolute; + top: 50%; + right: 50%; + transform: translate(50%, -50%); + } + } +`; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/index.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/index.tsx new file mode 100644 index 0000000000000..2311c016fc8b4 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/index.tsx @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState, useEffect } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; +import type { CoreStart } from '@kbn/core/public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { EuiErrorBoundary } from '@elastic/eui'; +import { useFetcher } from '@kbn/observability-plugin/public'; +import styled from 'styled-components'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { FormulaPublicApi } from '@kbn/lens-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { useAppDataView } from './use_app_data_view'; +import type { ExploratoryViewPublicPluginsStart } from '../../../..'; +import type { ExploratoryEmbeddableProps, ExploratoryEmbeddableComponentProps } from './embeddable'; + +const Embeddable = React.lazy(() => import('./embeddable')); + +function ExploratoryViewEmbeddable(props: ExploratoryEmbeddableComponentProps) { + return ( + }> + + + ); +} + +export function getExploratoryViewEmbeddable( + services: CoreStart & ExploratoryViewPublicPluginsStart +) { + const { lens, dataViews: dataViewsService, uiSettings } = services; + + const dataViewCache: Record = {}; + + const lenStateHelperPromise: Promise<{ formula: FormulaPublicApi }> | null = null; + + const lastRefreshed: Record = {}; + + const hasSameTimeRange = (props: ExploratoryEmbeddableProps) => { + const { attributes } = props; + if (!attributes || attributes?.length === 0) { + return false; + } + const series = attributes[0]; + const { time } = series; + const { from, to } = time; + return attributes.every((seriesT) => { + const { time: timeT } = seriesT; + return timeT.from === from && timeT.to === to; + }); + }; + + return (props: ExploratoryEmbeddableProps) => { + useEffect(() => { + if (!services.data.search.session.getSessionId()) { + services.data.search.session.start(); + } + }, []); + + const { dataTypesIndexPatterns, attributes, customHeight } = props; + + if (!dataViewsService || !lens || !attributes || attributes?.length === 0) { + return null; + } + + const series = attributes[0]; + + const isDarkMode = uiSettings?.get('theme:darkMode'); + + const { data: lensHelper, loading: lensLoading } = useFetcher(async () => { + if (lenStateHelperPromise) { + return lenStateHelperPromise; + } + return lens.stateHelperApi(); + }, []); + + const [loadCount, setLoadCount] = useState(0); + + const onLensLoaded = useCallback( + (lensLoaded: boolean) => { + if (lensLoaded && props.id && hasSameTimeRange(props) && !lastRefreshed[props.id]) { + lastRefreshed[props.id] = series.time; + } + setLoadCount((prev) => prev + 1); + }, + [props, series.time] + ); + + const { dataViews, loading } = useAppDataView({ + series, + dataViewCache, + dataViewsService, + dataTypesIndexPatterns, + seriesDataType: series?.dataType, + }); + + const embedProps = useMemo(() => { + const newProps = { ...props }; + if (props.sparklineMode) { + newProps.axisTitlesVisibility = { x: false, yRight: false, yLeft: false }; + newProps.legendIsVisible = false; + newProps.hideTicks = true; + } + if (props.id && lastRefreshed[props.id] && loadCount < 2) { + newProps.attributes = props.attributes?.map((seriesT) => ({ + ...seriesT, + time: lastRefreshed[props.id!], + })); + } else if (props.id) { + lastRefreshed[props.id] = series.time; + } + return newProps; + }, [loadCount, props, series.time]); + + if (Object.keys(dataViews).length === 0 || loading || !lensHelper || lensLoading) { + return ( + + + + ); + } + + if (!dataViews[series?.dataType]) { + return ; + } + + return ( + + + + + + + + + + ); + }; +} + +const Wrapper = styled.div<{ + customHeight?: string; +}>` + height: ${(props) => (props.customHeight ? `${props.customHeight};` : `100%;`)}; +`; + +const LoadingWrapper = styled.div<{ + customHeight?: string; +}>` + height: ${(props) => (props.customHeight ? `${props.customHeight};` : `100%;`)}; + display: flex; + align-items: center; + justify-content: center; +`; + +function EmptyState({ height }: { height?: string }) { + return ( + + + {NO_DATA_LABEL} + + + ); +} + +const NO_DATA_LABEL = i18n.translate('xpack.exploratoryView.overview.exploratoryView.noData', { + defaultMessage: 'No data', +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/use_actions.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/use_actions.ts new file mode 100644 index 0000000000000..07637831dc406 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/use_actions.ts @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { Action, ActionExecutionContext } from '@kbn/ui-actions-plugin/public'; +import { createExploratoryViewRoutePath } from '../configurations/utils'; +import { createExploratoryViewUrl } from '../configurations/exploratory_view_url'; +import { ReportViewType } from '../types'; +import { AllSeries } from '../hooks/use_series_storage'; +import { ObservabilityAppServices } from '../../../../application/types'; + +export type ActionTypes = 'explore' | 'save' | 'addToCase' | 'openInLens'; + +export function useActions({ + withActions, + attributes, + reportType, + setIsSaveOpen, + setAddToCaseOpen, + appId = 'observability', + timeRange, + lensAttributes, +}: { + withActions?: boolean | ActionTypes[]; + reportType: ReportViewType; + attributes: AllSeries; + appId?: 'securitySolutionUI' | 'observability'; + setIsSaveOpen: (val: boolean) => void; + setAddToCaseOpen: (val: boolean) => void; + timeRange: { from: string; to: string }; + lensAttributes: any; +}) { + const kServices = useKibana().services; + + const { lens } = kServices; + + const [defaultActions, setDefaultActions] = useState(['explore', 'save', 'addToCase']); + + useEffect(() => { + if (withActions === false) { + setDefaultActions([]); + } + if (Array.isArray(withActions)) { + setDefaultActions(withActions); + } + }, [withActions]); + + const { http, application } = useKibana().services; + + const href = createExploratoryViewUrl( + { reportType, allSeries: attributes }, + http?.basePath.get() + ); + + const routePath = createExploratoryViewRoutePath({ reportType, allSeries: attributes }); + + const openInLensCallback = useCallback(() => { + if (lensAttributes) { + lens.navigateToPrefilledEditor( + { + id: '', + timeRange, + attributes: lensAttributes, + }, + { + openInNewTab: true, + } + ); + } + }, [lens, lensAttributes, timeRange]); + + const exploreCallback = useCallback(() => { + application?.navigateToApp(appId, { path: routePath }); + }, [appId, application, routePath]); + + const saveCallback = useCallback(() => { + setIsSaveOpen(true); + }, [setIsSaveOpen]); + + const addToCaseCallback = useCallback(() => { + setAddToCaseOpen(true); + }, [setAddToCaseOpen]); + + return defaultActions.map((action) => { + if (action === 'save') { + return getSaveAction({ callback: saveCallback }); + } + if (action === 'addToCase') { + return getAddToCaseAction({ callback: addToCaseCallback }); + } + if (action === 'openInLens') { + return getOpenInLensAction({ callback: openInLensCallback }); + } + return getExploreAction({ href, callback: exploreCallback }); + }); +} + +const getOpenInLensAction = ({ callback }: { callback: () => void }): Action => { + return { + id: 'expViewOpenInLens', + getDisplayName(context: ActionExecutionContext): string { + return i18n.translate('xpack.exploratoryView.expView.openInLens', { + defaultMessage: 'Open in Lens', + }); + }, + getIconType(context: ActionExecutionContext): string | undefined { + return 'visArea'; + }, + type: 'link', + async isCompatible(context: ActionExecutionContext): Promise { + return true; + }, + async execute(context: ActionExecutionContext): Promise { + callback(); + return; + }, + }; +}; + +const getExploreAction = ({ href, callback }: { href: string; callback: () => void }): Action => { + return { + id: 'expViewExplore', + getDisplayName(context: ActionExecutionContext): string { + return i18n.translate('xpack.exploratoryView.expView.explore', { + defaultMessage: 'Explore', + }); + }, + getIconType(context: ActionExecutionContext): string | undefined { + return 'visArea'; + }, + type: 'link', + async isCompatible(context: ActionExecutionContext): Promise { + return true; + }, + async getHref(context: ActionExecutionContext): Promise { + return href; + }, + async execute(context: ActionExecutionContext): Promise { + callback(); + return; + }, + order: 50, + }; +}; + +const getSaveAction = ({ callback }: { callback: () => void }): Action => { + return { + id: 'expViewSave', + getDisplayName(context: ActionExecutionContext): string { + return i18n.translate('xpack.exploratoryView.expView.save', { + defaultMessage: 'Save visualization', + }); + }, + getIconType(context: ActionExecutionContext): string | undefined { + return 'save'; + }, + type: 'actionButton', + async isCompatible(context: ActionExecutionContext): Promise { + return true; + }, + async execute(context: ActionExecutionContext): Promise { + callback(); + return; + }, + order: 49, + }; +}; + +const getAddToCaseAction = ({ callback }: { callback: () => void }): Action => { + return { + id: 'expViewAddToCase', + getDisplayName(context: ActionExecutionContext): string { + return i18n.translate('xpack.exploratoryView.expView.addToCase', { + defaultMessage: 'Add to case', + }); + }, + getIconType(context: ActionExecutionContext): string | undefined { + return 'link'; + }, + type: 'actionButton', + async isCompatible(context: ActionExecutionContext): Promise { + return true; + }, + async execute(context: ActionExecutionContext): Promise { + callback(); + return; + }, + order: 48, + }; +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/use_app_data_view.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/use_app_data_view.ts new file mode 100644 index 0000000000000..ca2b44f74c30a --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/use_app_data_view.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState } from 'react'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { useFetcher } from '@kbn/observability-plugin/public'; +import { useLocalDataView } from './use_local_data_view'; +import { ExploratoryEmbeddableProps, ExploratoryViewPublicPluginsStart } from '../../../..'; +import type { DataViewState } from '../hooks/use_app_data_view'; +import type { AppDataType } from '../types'; +import { ObservabilityDataViews } from '../../../../utils/observability_data_views/observability_data_views'; +import { SeriesUrl } from '../../../..'; + +export const useAppDataView = ({ + series, + dataViewCache, + seriesDataType, + dataViewsService, + dataTypesIndexPatterns, +}: { + series: SeriesUrl; + seriesDataType: AppDataType; + dataViewCache: Record; + dataViewsService: ExploratoryViewPublicPluginsStart['dataViews']; + dataTypesIndexPatterns: ExploratoryEmbeddableProps['dataTypesIndexPatterns']; +}) => { + const [dataViews, setDataViews] = useState({} as DataViewState); + const { dataViewTitle } = useLocalDataView(seriesDataType, dataTypesIndexPatterns); + + const { loading } = useFetcher(async () => { + if (dataViewTitle && !dataViews[seriesDataType]) { + if (dataViewCache[dataViewTitle]) { + setDataViews((prevState) => ({ + ...(prevState ?? {}), + [seriesDataType]: dataViewCache[dataViewTitle], + })); + } else { + const obsvIndexP = new ObservabilityDataViews(dataViewsService, true); + const indPattern = await obsvIndexP.getDataView(seriesDataType, dataViewTitle); + dataViewCache[dataViewTitle] = indPattern!; + setDataViews((prevState) => ({ ...(prevState ?? {}), [seriesDataType]: indPattern })); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataViewTitle, seriesDataType, JSON.stringify(series)]); + + return { dataViews, loading }; +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/use_local_data_view.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/use_local_data_view.ts new file mode 100644 index 0000000000000..a02620e43feda --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/embeddable/use_local_data_view.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect } from 'react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { useFetcher } from '@kbn/observability-plugin/public'; +import { getDataTypeIndices } from '../../../../utils/observability_data_views'; +import { AppDataType } from '../types'; +import { ExploratoryEmbeddableProps } from '../../../..'; + +export function useLocalDataView( + seriesDataType: AppDataType, + dataTypesIndexPatterns: ExploratoryEmbeddableProps['dataTypesIndexPatterns'] +) { + const [dataViewTitle, setDataViewTitle] = useLocalStorage( + `${seriesDataType}AppDataViewTitle`, + '' + ); + + const initDatViewTitle = dataTypesIndexPatterns?.[seriesDataType]; + + const { data: updatedDataViewTitle } = useFetcher(async () => { + if (initDatViewTitle) { + return initDatViewTitle; + } + return (await getDataTypeIndices(seriesDataType)).indices; + }, [initDatViewTitle, seriesDataType]); + + useEffect(() => { + if (updatedDataViewTitle) { + setDataViewTitle(updatedDataViewTitle); + } + }, [setDataViewTitle, updatedDataViewTitle]); + + return { dataViewTitle: dataViewTitle || initDatViewTitle }; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/exploratory_view.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/exploratory_view.test.tsx new file mode 100644 index 0000000000000..9f50b22dc9f76 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/exploratory_view.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/dom'; +import { render, mockAppDataView } from './rtl_helpers'; +import { ExploratoryView } from './exploratory_view'; +import * as obsvDataViews from '../../../utils/observability_data_views/observability_data_views'; +import * as pluginHook from '../../../hooks/use_plugin_context'; +import { createStubIndexPattern } from '@kbn/data-plugin/common/stubs'; +import { noCasesPermissions as mockUseGetCasesPermissions } from '../../../utils/cases_permissions'; + +jest.spyOn(pluginHook, 'usePluginContext').mockReturnValue({ + appMountParameters: { + setHeaderActionMenu: jest.fn(), + }, +} as any); + +jest.mock('../../../hooks/use_get_user_cases_permissions', () => ({ + useGetUserCasesPermissions: jest.fn(() => mockUseGetCasesPermissions()), +})); + +describe('ExploratoryView', () => { + mockAppDataView(); + + beforeEach(() => { + const indexPattern = createStubIndexPattern({ + spec: { + id: 'apm-*', + title: 'apm-*', + timeFieldName: '@timestamp', + fields: { + '@timestamp': { + name: '@timestamp', + type: 'date', + esTypes: ['date'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + }, + }, + }); + + jest.spyOn(obsvDataViews, 'ObservabilityDataViews').mockReturnValue({ + getDataView: jest.fn().mockReturnValue(indexPattern), + } as any); + }); + + it('renders exploratory view', async () => { + render(, { initSeries: { data: [] } }); + + expect(await screen.findByText(/No series found. Please add a series./i)).toBeInTheDocument(); + expect(await screen.findByText(/Hide chart/i)).toBeInTheDocument(); + }); + + it('renders lens component when there is series', async () => { + render(); + + expect((await screen.findAllByText('Performance distribution'))[0]).toBeInTheDocument(); + expect(await screen.findByText(/Lens Embeddable Component/i)).toBeInTheDocument(); + + expect(screen.getByTestId('exploratoryViewSeriesPanel0')).toBeInTheDocument(); + }); + + it('shows/hides the chart', async () => { + render(); + + const toggleButton = await screen.findByText('Hide chart'); + expect(toggleButton).toBeInTheDocument(); + + toggleButton.click(); + + expect(toggleButton.textContent).toBe('Show chart'); + expect(screen.queryByText('Refresh')).toBeNull(); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/exploratory_view.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/exploratory_view.tsx new file mode 100644 index 0000000000000..cefe75c24b4cc --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/exploratory_view.tsx @@ -0,0 +1,230 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { + EuiButtonEmpty, + EuiResizableContainer, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { PanelDirection } from '@elastic/eui/src/components/resizable_container/types'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import { ExploratoryViewPublicPluginsStart } from '../../../plugin'; +import { useSeriesStorage } from './hooks/use_series_storage'; +import { useLensAttributes } from './hooks/use_lens_attributes'; +import { useAppDataViewContext } from './hooks/use_app_data_view'; +import { SeriesViews } from './views/series_views'; +import { LensEmbeddable } from './lens_embeddable'; +import { EmptyView } from './components/empty_view'; +import { useExpViewTimeRange } from './hooks/use_time_range'; +import { ExpViewActionMenu } from './components/action_menu'; +import { useExploratoryView } from './contexts/exploratory_view_config'; + +export type PanelId = 'seriesPanel' | 'chartPanel'; + +export function ExploratoryView({ + saveAttributes, +}: { + saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void; +}) { + const { + services: { lens }, + } = useKibana(); + const seriesBuilderRef = useRef(null); + const wrapperRef = useRef(null); + + const [height, setHeight] = useState('100vh'); + + const { isEditMode } = useExploratoryView(); + + const [lensAttributes, setLensAttributes] = useState( + null + ); + + const { loadDataView, loading } = useAppDataViewContext(); + + const { firstSeries, allSeries, lastRefresh, reportType, setChartTimeRangeContext } = + useSeriesStorage(); + + const lensAttributesT = useLensAttributes(); + const timeRange = useExpViewTimeRange(); + + const setHeightOffset = () => { + if (seriesBuilderRef?.current && wrapperRef.current) { + const headerOffset = wrapperRef.current.getBoundingClientRect().top; + setHeight(`calc(100vh - ${headerOffset + 40}px)`); + } + }; + + useEffect(() => { + allSeries.forEach((seriesT) => { + loadDataView({ + dataType: seriesT.dataType, + }); + }); + }, [allSeries, loadDataView]); + + useEffect(() => { + setLensAttributes(lensAttributesT); + if (saveAttributes) { + saveAttributes(lensAttributesT); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(lensAttributesT ?? {}), lastRefresh]); + + useEffect(() => { + setHeightOffset(); + }); + + const collapseFn = useRef<(id: PanelId, direction: PanelDirection) => void>(); + + const [hiddenPanel, setHiddenPanel] = useState(''); + + const onCollapse = (panelId: string) => { + setHiddenPanel((prevState) => (panelId === prevState ? '' : panelId)); + }; + + const onChange = (panelId: PanelId) => { + onCollapse(panelId); + if (collapseFn.current) { + collapseFn.current(panelId, panelId === 'seriesPanel' ? 'right' : 'left'); + } + }; + + return lens ? ( + <> + + + + {(EuiResizablePanel, _EuiResizableButton, { togglePanel }) => { + collapseFn.current = (id, direction) => togglePanel?.(id, { direction }); + + return ( + <> + + + onChange('chartPanel')} + > + {hiddenPanel === 'chartPanel' ? SHOW_CHART_LABEL : HIDE_CHART_LABEL} + + + + + + {lensAttributes ? ( + + ) : ( + + )} + + + + + + + ); + }} + + {hiddenPanel === 'seriesPanel' && ( + onChange('seriesPanel')} iconType="arrowUp"> + {PREVIEW_LABEL} + + )} + + + ) : ( + +

{LENS_NOT_AVAILABLE}

+
+ ); +} +const LensWrapper = styled.div<{ height: string }>` + min-height: 400px; + height: ${(props) => props.height}; + + &&& > div { + height: 100%; + } +`; + +const ResizableContainer = styled(EuiResizableContainer)` + height: 100%; + &&& .paddingTopSmall { + padding-top: 8px; + } + #chartPanel { + > .euiPanel { + padding-bottom: 0; + padding-top: 0; + } + .expExpressionRenderer__expression { + padding-bottom: 0 !important; + padding-top: 0 !important; + } + } +`; + +const ShowPreview = styled(EuiButtonEmpty)` + position: absolute; + bottom: 34px; +`; + +const PREVIEW_LABEL = i18n.translate('xpack.exploratoryView.overview.exploratoryView.preview', { + defaultMessage: 'Preview', +}); + +const HIDE_CHART_LABEL = i18n.translate( + 'xpack.exploratoryView.overview.exploratoryView.hideChart', + { + defaultMessage: 'Hide chart', + } +); + +const SHOW_CHART_LABEL = i18n.translate( + 'xpack.exploratoryView.overview.exploratoryView.showChart', + { + defaultMessage: 'Show chart', + } +); + +const LENS_NOT_AVAILABLE = i18n.translate( + 'xpack.exploratoryView.overview.exploratoryView.lensDisabled', + { + defaultMessage: 'Lens app is not available, please enable Lens to use exploratory view.', + } +); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx new file mode 100644 index 0000000000000..e6f432f4f30cb --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, forNearestButton } from '../rtl_helpers'; +import { fireEvent } from '@testing-library/dom'; +import { AddToCaseAction } from './add_to_case_action'; +import * as useCaseHook from '../hooks/use_add_to_case'; +import * as datePicker from '../components/date_range_picker'; +import moment from 'moment'; +import { noCasesPermissions as mockUseGetCasesPermissions } from '../../../../utils/cases_permissions'; + +jest.mock('../../../../hooks/use_get_user_cases_permissions', () => ({ + useGetUserCasesPermissions: jest.fn(() => mockUseGetCasesPermissions()), +})); + +describe('AddToCaseAction', function () { + beforeEach(() => { + jest.spyOn(datePicker, 'parseRelativeDate').mockRestore(); + }); + + it('should render properly', async function () { + const { findByText } = render( + + ); + expect(await findByText('Add to case')).toBeInTheDocument(); + }); + + it('should parse relative data to the useAddToCase hook', async function () { + const useAddToCaseHook = jest.spyOn(useCaseHook, 'useAddToCase'); + jest.spyOn(datePicker, 'parseRelativeDate').mockReturnValue(moment('2021-11-10T10:52:06.091Z')); + + const { findByText } = render( + + ); + expect(await findByText('Add to case')).toBeInTheDocument(); + + expect(useAddToCaseHook).toHaveBeenCalledWith( + expect.objectContaining({ + lensAttributes: { + title: 'Performance distribution', + }, + timeRange: { + from: '2021-11-10T10:52:06.091Z', + to: '2021-11-10T10:52:06.091Z', + }, + }) + ); + }); + + it('should use an empty time-range when timeRanges are empty', async function () { + const useAddToCaseHook = jest.spyOn(useCaseHook, 'useAddToCase'); + + const { getByText } = render( + + ); + + expect(await forNearestButton(getByText)('Add to case')).toBeDisabled(); + + expect(useAddToCaseHook).toHaveBeenCalledWith( + expect.objectContaining({ + lensAttributes: null, + timeRange: { + from: '', + to: '', + }, + appId: 'securitySolutionUI', + owner: 'security', + }) + ); + }); + + it('should be able to click add to case button', async function () { + const initSeries = { + data: [ + { + name: 'test-series', + dataType: 'synthetics' as const, + reportType: 'kpi-over-time' as const, + breakdown: 'monitor.status', + time: { from: 'now-15m', to: 'now' }, + }, + ], + }; + + const { findByText, core } = render( + , + { initSeries } + ); + fireEvent.click(await findByText('Add to case')); + + expect(core?.cases?.ui.getAllCasesSelectorModal).toHaveBeenCalledTimes(1); + expect(core?.cases?.ui.getAllCasesSelectorModal).toHaveBeenCalledWith( + expect.objectContaining({ + owner: ['observability'], + permissions: { + all: false, + create: false, + read: false, + update: false, + delete: false, + push: false, + }, + }) + ); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/add_to_case_action.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/add_to_case_action.tsx new file mode 100644 index 0000000000000..2b0f2a4ffc190 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/add_to_case_action.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useEffect } from 'react'; +import { toMountPoint, useKibana } from '@kbn/kibana-react-plugin/public'; +import { + CasesDeepLinkId, + generateCaseViewPath, + GetAllCasesSelectorModalProps, +} from '@kbn/cases-plugin/public'; +import { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import { observabilityFeatureId, observabilityAppId } from '@kbn/observability-plugin/public'; +import { useGetUserCasesPermissions } from '../../../../hooks/use_get_user_cases_permissions'; +import { ObservabilityAppServices } from '../../../../application/types'; +import { useAddToCase } from '../hooks/use_add_to_case'; +import { parseRelativeDate } from '../components/date_range_picker'; + +export interface AddToCaseProps { + appId?: 'securitySolutionUI' | 'observability'; + autoOpen?: boolean; + lensAttributes: TypedLensByValueInput['attributes'] | null; + owner?: string; + setAutoOpen?: (val: boolean) => void; + timeRange: { from: string; to: string }; +} + +export function AddToCaseAction({ + appId, + autoOpen, + lensAttributes, + owner = observabilityFeatureId, + setAutoOpen, + timeRange, +}: AddToCaseProps) { + const kServices = useKibana().services; + const userCasesPermissions = useGetUserCasesPermissions(); + + const { + cases, + application: { getUrlForApp }, + theme, + } = kServices; + + const getToastText = useCallback( + (theCase) => + toMountPoint( + , + { theme$: theme?.theme$ } + ), + [appId, getUrlForApp, theme?.theme$] + ); + + const absoluteFromDate = parseRelativeDate(timeRange.from); + const absoluteToDate = parseRelativeDate(timeRange.to, { roundUp: true }); + + const { onCaseClicked, isCasesOpen, setIsCasesOpen, isSaving } = useAddToCase({ + lensAttributes, + getToastText, + timeRange: { + from: absoluteFromDate?.toISOString() ?? '', + to: absoluteToDate?.toISOString() ?? '', + }, + appId, + owner, + }); + + const getAllCasesSelectorModalProps: GetAllCasesSelectorModalProps = { + permissions: userCasesPermissions, + onRowClick: onCaseClicked, + owner: [owner], + onClose: () => { + setIsCasesOpen(false); + }, + }; + + useEffect(() => { + if (autoOpen) { + setIsCasesOpen(true); + } + }, [autoOpen, setIsCasesOpen]); + + useEffect(() => { + if (!isCasesOpen) { + setAutoOpen?.(false); + } + }, [isCasesOpen, setAutoOpen]); + + return ( + <> + {typeof autoOpen === 'undefined' && ( + { + if (lensAttributes) { + setIsCasesOpen(true); + } + }} + > + {i18n.translate('xpack.exploratoryView.expView.heading.addToCase', { + defaultMessage: 'Add to case', + })} + + )} + {isCasesOpen && + lensAttributes && + cases.ui.getAllCasesSelectorModal(getAllCasesSelectorModalProps)} + + ); +} + +export function CaseToastText({ linkUrl }: { linkUrl: string }) { + return ( + + + + {i18n.translate('xpack.exploratoryView.expView.heading.addToCase.notification.viewCase', { + defaultMessage: 'View case', + })} + + + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/chart_creation_info.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/chart_creation_info.test.tsx new file mode 100644 index 0000000000000..570362a63c33f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/chart_creation_info.test.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 from 'react'; +import { screen } from '@testing-library/dom'; +import { render } from '../rtl_helpers'; +import { ChartCreationInfo } from './chart_creation_info'; + +const info = { + to: 1634071132571, + from: 1633406400000, + lastUpdated: 1634071140788, +}; + +describe('ChartCreationInfo', () => { + it('renders chart creation info', async () => { + render(); + + expect(screen.getByText('Chart created')).toBeInTheDocument(); + expect(screen.getByText('Oct 12, 2021 4:39 PM')).toBeInTheDocument(); + expect(screen.getByText('Displaying from')).toBeInTheDocument(); + expect(screen.getByText('Oct 5, 2021 12:00 AM → Oct 12, 2021 4:38 PM')).toBeInTheDocument(); + }); + + it('does not display info when props are falsey', async () => { + render(); + + expect(screen.queryByText('Chart created')).not.toBeInTheDocument(); + expect(screen.queryByText('Displaying from')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/chart_creation_info.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/chart_creation_info.tsx new file mode 100644 index 0000000000000..82258f77fefee --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/chart_creation_info.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import moment from 'moment'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer } from '@elastic/eui'; +import type { ChartTimeRange } from './last_updated'; + +export function ChartCreationInfo(props: Partial) { + const dateFormat = 'lll'; + const from = moment(props.from).format(dateFormat); + const to = moment(props.to).format(dateFormat); + const created = moment(props.lastUpdated).format(dateFormat); + + return ( + <> + {props.lastUpdated && ( + <> + + + + + + + + {created} + + + + + )} + {props.to && props.from && ( + <> + + + + + + + + + {from} → {to} + + + + + )} + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/embed_action.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/embed_action.tsx new file mode 100644 index 0000000000000..e76e5068d5911 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/embed_action.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonEmpty, EuiPopover, EuiCodeBlock, EuiPopoverTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import { useSeriesStorage } from '../hooks/use_series_storage'; + +export function EmbedAction({ + lensAttributes, +}: { + lensAttributes: TypedLensByValueInput['attributes'] | null; +}) { + const [isOpen, setIsOpen] = useState(false); + + const { reportType, allSeries } = useSeriesStorage(); + + const button = ( + { + setIsOpen(!isOpen); + }} + > + {EMBED_LABEL} + + ); + + return ( + setIsOpen(false)}> + {EMBED_TITLE_LABEL} + + {`const { observability } = useKibana<>().services; + +const { ExploratoryViewEmbeddable } = observability; + + + `} + + + ); +} + +const EMBED_TITLE_LABEL = i18n.translate('xpack.exploratoryView.expView.heading.embedTitle', { + defaultMessage: 'Embed Exploratory view (Dev only feature)', +}); + +const EMBED_LABEL = i18n.translate('xpack.exploratoryView.expView.heading.embed', { + defaultMessage: 'Embed <>', +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/last_updated.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/last_updated.tsx new file mode 100644 index 0000000000000..f3d8f4cfdec00 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/last_updated.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; +import moment from 'moment'; +import styled from 'styled-components'; +import { EuiIcon, EuiText, EuiToolTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { ChartCreationInfo } from './chart_creation_info'; + +export interface ChartTimeRange { + lastUpdated: number; + to?: number; + from?: number; +} + +interface Props { + chartTimeRange?: ChartTimeRange; +} + +export function LastUpdated({ chartTimeRange }: Props) { + const { lastUpdated } = chartTimeRange || {}; + const [refresh, setRefresh] = useState(() => Date.now()); + + useEffect(() => { + const interVal = setInterval(() => { + setRefresh(Date.now()); + }, 5000); + + return () => { + clearInterval(interVal); + }; + }, []); + + useEffect(() => { + setRefresh(Date.now()); + }, [lastUpdated]); + + if (!lastUpdated) { + return null; + } + + const isWarning = moment().diff(moment(lastUpdated), 'minute') > 5; + const isDanger = moment().diff(moment(lastUpdated), 'minute') > 10; + + return ( + + } + > + + {' '} + + + ); +} + +export const StyledToolTipWrapper = styled.div` + min-width: 30vw; +`; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/refresh_button.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/refresh_button.tsx new file mode 100644 index 0000000000000..7101cbceccb07 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/header/refresh_button.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { LastUpdated } from './last_updated'; +import { useSeriesStorage } from '../hooks/use_series_storage'; + +export function RefreshButton() { + const { setLastRefresh, chartTimeRangeContext } = useSeriesStorage(); + + return ( + + + + + + setLastRefresh(Date.now())} + > + {REFRESH_LABEL} + + + + ); +} + +export const REFRESH_LABEL = i18n.translate( + 'xpack.exploratoryView.overview.exploratoryView.refresh', + { + defaultMessage: 'Refresh', + } +); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_add_to_case.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_add_to_case.test.tsx new file mode 100644 index 0000000000000..f1b81483c9b3e --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_add_to_case.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useAddToCase } from './use_add_to_case'; +import React, { useEffect } from 'react'; +import { render } from '../rtl_helpers'; +import { EuiButton } from '@elastic/eui'; +import { fireEvent } from '@testing-library/dom'; +import { act } from '@testing-library/react'; + +describe('useAddToCase', function () { + function setupTestComponent() { + const setData = jest.fn(); + function TestComponent() { + const getToastText = jest.fn(); + + const result = useAddToCase({ + lensAttributes: { title: 'Test lens attributes' } as any, + timeRange: { to: 'now', from: 'now-5m' }, + getToastText, + }); + useEffect(() => { + setData(result); + }, [result]); + + return ( + + result.onCaseClicked()} + > + Add new case + + result.onCaseClicked({ id: 'test' } as any)} + > + On case click + + + ); + } + + const renderSetup = render(); + + return { setData, ...renderSetup }; + } + it('should return expected result', async function () { + const { setData, core, findByText } = setupTestComponent(); + + expect(setData).toHaveBeenLastCalledWith({ + isCasesOpen: false, + isSaving: false, + onCaseClicked: expect.any(Function), + setIsCasesOpen: expect.any(Function), + }); + + await act(async () => { + fireEvent.click(await findByText('Add new case')); + }); + + expect(core.application?.navigateToApp).toHaveBeenCalledTimes(1); + expect(core.application?.navigateToApp).toHaveBeenCalledWith('observability', { + deepLinkId: 'cases_create', + openInNewTab: true, + }); + + await act(async () => { + fireEvent.click(await findByText('On case click')); + }); + + expect(core.http?.post).toHaveBeenCalledTimes(1); + expect(core.http?.post).toHaveBeenCalledWith('/api/cases/test/comments', { + body: '{"comment":"!{lens{\\"attributes\\":{\\"title\\":\\"Test lens attributes\\"},\\"timeRange\\":{\\"to\\":\\"now\\",\\"from\\":\\"now-5m\\"}}}","type":"user","owner":"observability"}', + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_add_to_case.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_add_to_case.ts new file mode 100644 index 0000000000000..0d2b9dd724077 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_add_to_case.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { HttpSetup, MountPoint } from '@kbn/core/public'; +import { Case } from '@kbn/cases-plugin/common'; +import { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import { CasesDeepLinkId, DRAFT_COMMENT_STORAGE_ID } from '@kbn/cases-plugin/public'; +import { observabilityFeatureId } from '@kbn/observability-plugin/public'; +import { useKibana } from '../../../../utils/kibana_react'; +import { AddToCaseProps } from '../header/add_to_case_action'; + +async function addToCase( + http: HttpSetup, + theCase: Case, + attributes: TypedLensByValueInput['attributes'], + timeRange?: { from: string; to: string }, + owner?: string +) { + const apiPath = `/api/cases/${theCase?.id}/comments`; + + const vizPayload = { + attributes, + timeRange, + }; + + const payload = { + comment: `!{lens${JSON.stringify(vizPayload)}}`, + type: 'user', + owner: owner ?? observabilityFeatureId, + }; + + return http.post(apiPath, { body: JSON.stringify(payload) }); +} + +export const useAddToCase = ({ + lensAttributes, + getToastText, + timeRange, + appId, + owner = observabilityFeatureId, +}: AddToCaseProps & { + appId?: 'securitySolutionUI' | 'observability'; + getToastText: (thaCase: Case) => MountPoint; +}) => { + const [isSaving, setIsSaving] = useState(false); + const [isCasesOpen, setIsCasesOpen] = useState(false); + + const { + http, + application: { navigateToApp }, + notifications: { toasts }, + storage, + } = useKibana().services; + + const onCaseClicked = useCallback( + (theCase?: Case) => { + if (theCase && lensAttributes) { + setIsCasesOpen(false); + setIsSaving(true); + addToCase(http, theCase, lensAttributes, timeRange, owner).then( + () => { + setIsSaving(false); + toasts.addSuccess( + { + title: i18n.translate( + 'xpack.exploratoryView.expView.heading.addToCase.notification', + { + defaultMessage: 'Successfully added visualization to the case: {caseTitle}', + values: { caseTitle: theCase.title }, + } + ), + text: getToastText(theCase), + }, + { + toastLifeTimeMs: 10000, + } + ); + }, + (error) => { + toasts.addError(error, { + title: i18n.translate( + 'xpack.exploratoryView.expView.heading.addToCase.notification.error', + { + defaultMessage: 'Failed to add visualization to the selected case.', + } + ), + }); + } + ); + } else { + /* save lens attributes and timerange to local storage, + ** so the description field will be automatically filled on case creation page. + */ + storage?.set(DRAFT_COMMENT_STORAGE_ID, { + commentId: 'description', + comment: `!{lens${JSON.stringify({ + timeRange, + attributes: lensAttributes, + })}}`, + position: '', + caseTitle: '', + caseTags: '', + }); + navigateToApp(appId ?? observabilityFeatureId, { + deepLinkId: CasesDeepLinkId.casesCreate, + openInNewTab: true, + }); + } + }, + [appId, getToastText, http, lensAttributes, navigateToApp, owner, storage, timeRange, toasts] + ); + + return { + onCaseClicked, + isSaving, + isCasesOpen, + setIsCasesOpen, + }; +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_app_data_view.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_app_data_view.tsx new file mode 100644 index 0000000000000..f8aaa4e64f2ac --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_app_data_view.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, useContext, Context, useState, useCallback, useMemo } from 'react'; +import type { IHttpFetchError } from '@kbn/core-http-browser'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { DataViewInsufficientAccessError } from '@kbn/data-views-plugin/common'; +import { AppDataType } from '../types'; +import { ExploratoryViewPublicPluginsStart } from '../../../../plugin'; +import { + getDataTypeIndices, + ObservabilityDataViews, +} from '../../../../utils/observability_data_views'; + +export interface DataViewContext { + loading: boolean; + dataViews: DataViewState; + dataViewErrors: DataViewErrors; + hasAppData: HasAppDataState; + loadDataView: (params: { dataType: AppDataType }) => void; +} + +export const DataViewContext = createContext>({}); + +interface ProviderProps { + children: JSX.Element; +} + +type HasAppDataState = Record; +export type DataViewState = Record; +export type DataViewErrors = Record>; +type LoadingState = Record; + +export function DataViewContextProvider({ children }: ProviderProps) { + const [loading, setLoading] = useState({} as LoadingState); + const [dataViews, setDataViews] = useState({} as DataViewState); + const [dataViewErrors, setDataViewErrors] = useState({} as DataViewErrors); + const [hasAppData, setHasAppData] = useState({} as HasAppDataState); + + const { + services: { dataViews: dataViewsService }, + } = useKibana(); + + const loadDataView: DataViewContext['loadDataView'] = useCallback( + async ({ dataType }) => { + if (typeof hasAppData[dataType] === 'undefined' && !loading[dataType]) { + setLoading((prevState) => ({ ...prevState, [dataType]: true })); + try { + const { indices, hasData } = await getDataTypeIndices(dataType); + + setHasAppData((prevState) => ({ ...prevState, [dataType]: hasData })); + + if (hasData && indices) { + const obsvDataV = new ObservabilityDataViews(dataViewsService, true); + const dataV = await obsvDataV.getDataView(dataType, indices); + + setDataViews((prevState) => ({ ...prevState, [dataType]: dataV })); + } + setLoading((prevState) => ({ ...prevState, [dataType]: false })); + } catch (e) { + if ( + e instanceof DataViewInsufficientAccessError || + (e as IHttpFetchError).body === 'Forbidden' + ) { + setDataViewErrors((prevState) => ({ ...prevState, [dataType]: e })); + } + setLoading((prevState) => ({ ...prevState, [dataType]: false })); + } + } + }, + [dataViewsService, hasAppData, loading] + ); + + return ( + loadingT), + }} + > + {children} + + ); +} + +export const useAppDataViewContext = (dataType?: AppDataType) => { + const { loading, hasAppData, loadDataView, dataViews, dataViewErrors } = useContext( + DataViewContext as unknown as Context + ); + + if (dataType && !dataViews?.[dataType] && !loading) { + loadDataView({ dataType }); + } + + return useMemo(() => { + return { + hasAppData, + loading, + dataViews, + dataViewErrors, + dataView: dataType ? dataViews?.[dataType] : undefined, + hasData: dataType ? hasAppData?.[dataType] : undefined, + loadDataView, + }; + }, [dataType, hasAppData, dataViewErrors, dataViews, loadDataView, loading]); +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_discover_link.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_discover_link.tsx new file mode 100644 index 0000000000000..d3a9d16be8c0f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_discover_link.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { Filter } from '@kbn/es-query'; +import { useKibana } from '../../../../utils/kibana_react'; +import { SeriesConfig, SeriesUrl } from '../types'; +import { useAppDataViewContext } from './use_app_data_view'; +import { buildExistsFilter, urlFilterToPersistedFilter } from '../configurations/utils'; +import { getFiltersFromDefs } from './use_lens_attributes'; +import { RECORDS_FIELD, RECORDS_PERCENTAGE_FIELD } from '../configurations/constants'; + +interface UseDiscoverLink { + seriesConfig?: SeriesConfig; + series: SeriesUrl; +} + +export const useDiscoverLink = ({ series, seriesConfig }: UseDiscoverLink) => { + const kServices = useKibana().services; + const { + application: { navigateToUrl }, + } = kServices; + + const { dataViews } = useAppDataViewContext(); + + const locator = kServices.discover?.locator; + const [discoverUrl, setDiscoverUrl] = useState(''); + + useEffect(() => { + const dataView = dataViews?.[series.dataType]; + + if (dataView) { + const definitions = series.reportDefinitions ?? {}; + + const urlFilters = (series.filters ?? []).concat(getFiltersFromDefs(definitions)); + + const filters = urlFilterToPersistedFilter({ + dataView, + urlFilters, + initFilters: seriesConfig?.baseFilters, + }) as Filter[]; + + const selectedMetricField = series.selectedMetricField; + + if ( + selectedMetricField && + selectedMetricField !== RECORDS_FIELD && + selectedMetricField !== RECORDS_PERCENTAGE_FIELD + ) { + filters.push(buildExistsFilter(selectedMetricField, dataView)[0]); + } + + const getDiscoverUrl = async () => { + if (!locator) return; + + const newUrl = await locator.getUrl({ + filters, + indexPatternId: dataView?.id, + }); + setDiscoverUrl(newUrl); + }; + getDiscoverUrl(); + } + }, [ + dataViews, + series.dataType, + series.filters, + series.reportDefinitions, + series.selectedMetricField, + seriesConfig?.baseFilters, + locator, + ]); + + const onClick = useCallback( + (event: React.MouseEvent) => { + if (discoverUrl) { + event.preventDefault(); + + return navigateToUrl(discoverUrl); + } + }, + [discoverUrl, navigateToUrl] + ); + + return { + href: discoverUrl, + onClick, + }; +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_lens_attributes.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_lens_attributes.test.tsx new file mode 100644 index 0000000000000..d98a8a8260cbd --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_lens_attributes.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { allSeriesKey, reportTypeKey, UrlStorageContextProvider } from './use_series_storage'; +import { renderHook } from '@testing-library/react-hooks'; +import { useLensAttributes } from './use_lens_attributes'; +import { ReportTypes } from '../configurations/constants'; +import { mockDataView } from '../rtl_helpers'; +import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; +import { TRANSACTION_DURATION } from '../configurations/constants/elasticsearch_fieldnames'; +import * as lensAttributes from '../configurations/lens_attributes'; +import * as useAppDataViewHook from './use_app_data_view'; +import * as theme from '../../../../hooks/use_theme'; +import { dataTypes, obsvReportConfigMap, reportTypesList } from '../obsv_exploratory_view'; +import { ExploratoryViewContextProvider } from '../contexts/exploratory_view_config'; +import { themeServiceMock } from '@kbn/core/public/mocks'; +import * as lensHook from './use_lens_formula_helper'; +import { lensPluginMock } from '@kbn/lens-plugin/public/mocks'; +import { FormulaPublicApi } from '@kbn/lens-plugin/public'; + +const mockSingleSeries = [ + { + name: 'performance-distribution', + dataType: 'ux', + breakdown: 'user_agent.name', + time: { from: 'now-15m', to: 'now' }, + selectedMetricField: TRANSACTION_DURATION, + reportDefinitions: { 'service.name': ['elastic-co'] }, + }, +]; + +describe('useExpViewTimeRange', function () { + const storage = createKbnUrlStateStorage({ useHash: false }); + // @ts-ignore + jest.spyOn(useAppDataViewHook, 'useAppDataViewContext').mockReturnValue({ + dataViews: { + ux: mockDataView, + apm: mockDataView, + mobile: mockDataView, + infra_logs: mockDataView, + infra_metrics: mockDataView, + synthetics: mockDataView, + uptime: mockDataView, + alerts: mockDataView, + }, + }); + jest.spyOn(theme, 'useTheme').mockReturnValue({ + // @ts-ignore + eui: { + euiColorVis1: '#111111', + }, + }); + + let formulaHelper: FormulaPublicApi; + + beforeAll(async () => { + const lensPluginMockStart = lensPluginMock.createStartContract(); + formulaHelper = (await lensPluginMockStart.stateHelperApi()).formula; + + jest.spyOn(lensHook, 'useLensFormulaHelper').mockReturnValue(formulaHelper); + }); + + const lensAttributesSpy = jest.spyOn(lensAttributes, 'LensAttributes'); + + function Wrapper({ children }: { children: JSX.Element }) { + return ( + + {children} + + ); + } + + it('updates lens attributes with report type from storage', async function () { + await storage.set(allSeriesKey, mockSingleSeries); + await storage.set(reportTypeKey, ReportTypes.KPI); + + renderHook(() => useLensAttributes(), { + wrapper: Wrapper, + }); + + expect(lensAttributesSpy).toBeCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + seriesConfig: expect.objectContaining({ reportType: ReportTypes.KPI }), + }), + ]), + 'kpi-over-time', + formulaHelper + ); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts new file mode 100644 index 0000000000000..690da3d98704a --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { isEmpty } from 'lodash'; +import { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import { EuiTheme } from '@kbn/kibana-react-plugin/common'; +import { useKibanaSpace } from '../../../../hooks/use_kibana_space'; +import { HeatMapLensAttributes } from '../configurations/lens_attributes/heatmap_attributes'; +import { useLensFormulaHelper } from './use_lens_formula_helper'; +import { ALL_VALUES_SELECTED } from '../configurations/constants/url_constants'; +import { LayerConfig, LensAttributes } from '../configurations/lens_attributes'; +import { + AllSeries, + allSeriesKey, + convertAllShortSeries, + reportTypeKey, + useSeriesStorage, +} from './use_series_storage'; +import { getDefaultConfigs } from '../configurations/default_configs'; +import { ReportViewType, SeriesUrl, UrlFilter } from '../types'; +import { DataViewState, useAppDataViewContext } from './use_app_data_view'; +import { useTheme } from '../../../../hooks/use_theme'; +import { LABEL_FIELDS_BREAKDOWN } from '../configurations/constants'; +import { ReportConfigMap, useExploratoryView } from '../contexts/exploratory_view_config'; +import { SingleMetricLensAttributes } from '../configurations/lens_attributes/single_metric_attributes'; + +export const getFiltersFromDefs = ( + reportDefinitions: SeriesUrl['reportDefinitions'] | SeriesUrl['textReportDefinitions'] +) => { + return Object.entries(reportDefinitions ?? {}) + .map(([field, value]) => { + return { + field, + values: Array.isArray(value) ? value : [value], + }; + }) + .filter(({ values }) => !values.includes(ALL_VALUES_SELECTED)) as UrlFilter[]; +}; + +export function getLayerConfigs( + allSeries: AllSeries, + reportType: ReportViewType, + theme: EuiTheme, + dataViews: DataViewState, + reportConfigMap: ReportConfigMap, + spaceId?: string +) { + const layerConfigs: LayerConfig[] = []; + + allSeries.forEach((series, seriesIndex) => { + const dataView = dataViews?.[series?.dataType]; + + if ( + dataView && + !isEmpty(series.reportDefinitions) && + !series.hidden && + series.selectedMetricField + ) { + const seriesConfig = getDefaultConfigs({ + reportType, + dataView, + dataType: series.dataType, + reportConfigMap, + spaceId, + }); + + const filters: UrlFilter[] = (series.filters ?? []).concat( + getFiltersFromDefs(series.reportDefinitions), + getFiltersFromDefs(series.textReportDefinitions) + ); + + const color = (theme.eui as unknown as Record)?.[`euiColorVis${seriesIndex}`]; + let seriesColor = series.color!; + if (reportType !== 'single-metric') { + seriesColor = series.color ?? color; + } + + layerConfigs.push({ + filters, + dataView, + seriesConfig, + time: series.time, + name: series.name, + color: seriesColor, + breakdown: series.breakdown === LABEL_FIELDS_BREAKDOWN ? undefined : series.breakdown, + seriesType: series.seriesType, + operationType: series.operationType, + reportDefinitions: series.reportDefinitions ?? {}, + selectedMetricField: series.selectedMetricField, + showPercentileAnnotations: series.showPercentileAnnotations, + }); + } + }); + + return layerConfigs; +} + +export const useLensAttributes = (): TypedLensByValueInput['attributes'] | null => { + const { storage, allSeries, lastRefresh, reportType } = useSeriesStorage(); + + const { dataViews } = useAppDataViewContext(); + + const spaceId = useKibanaSpace(); + + const { reportConfigMap } = useExploratoryView(); + + const theme = useTheme(); + + const lensFormulaHelper = useLensFormulaHelper(); + + return useMemo(() => { + // we only use the data from url to apply, since that gets updated to apply changes + const allSeriesT: AllSeries = convertAllShortSeries(storage.get(allSeriesKey) ?? []); + const reportTypeT: ReportViewType = storage.get(reportTypeKey) as ReportViewType; + + if (isEmpty(dataViews) || isEmpty(allSeriesT) || !reportTypeT || !lensFormulaHelper) { + return null; + } + const layerConfigs = getLayerConfigs( + allSeriesT, + reportTypeT, + theme, + dataViews, + reportConfigMap, + spaceId.space?.id + ); + + if (layerConfigs.length < 1) { + return null; + } + + if (reportTypeT === 'single-metric') { + const lensAttributes = new SingleMetricLensAttributes( + layerConfigs, + reportTypeT, + lensFormulaHelper + ); + + return lensAttributes.getJSON('lnsLegacyMetric', lastRefresh); + } + + if (reportTypeT === 'heatmap') { + const lensAttributes = new HeatMapLensAttributes( + layerConfigs, + reportTypeT, + lensFormulaHelper + ); + + return lensAttributes.getJSON('lnsHeatmap', lastRefresh); + } + + const lensAttributes = new LensAttributes(layerConfigs, reportTypeT, lensFormulaHelper); + + return lensAttributes.getJSON('lnsXY', lastRefresh); + // we also want to check the state on allSeries changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataViews, reportType, storage, theme, lastRefresh, allSeries, lensFormulaHelper]); +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_lens_formula_helper.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_lens_formula_helper.ts new file mode 100644 index 0000000000000..9a2b7c115051c --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_lens_formula_helper.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useFetcher } from '@kbn/observability-plugin/public'; +import { ExploratoryViewPublicPluginsStart } from '../../../..'; + +export const useLensFormulaHelper = () => { + const { + services: { lens }, + } = useKibana(); + + const { data: lensHelper } = useFetcher(async () => { + return lens.stateHelperApi(); + }, [lens]); + + return useMemo(() => { + if (lensHelper) { + return lensHelper.formula; + } + }, [lensHelper]); +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_series_filters.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_series_filters.ts new file mode 100644 index 0000000000000..253db8a42dd7c --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_series_filters.ts @@ -0,0 +1,188 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { concat } from 'lodash'; +import { useSeriesStorage } from './use_series_storage'; +import { SeriesUrl, UrlFilter } from '../types'; + +export interface UpdateFilter { + field: string; + value: string | Array; + negate?: boolean; + wildcards?: string[]; + isWildcard?: boolean; +} + +export const useSeriesFilters = ({ seriesId, series }: { seriesId: number; series: SeriesUrl }) => { + const { setSeries } = useSeriesStorage(); + + const filters = series.filters ?? []; + + const replaceFilter = ({ + field, + values, + notValues, + wildcards, + notWildcards, + }: { + field: string; + values: Array; + notValues: Array; + wildcards?: string[]; + notWildcards?: string[]; + }) => { + const currFilter: UrlFilter | undefined = filters.find(({ field: fd }) => field === fd) ?? { + field, + }; + + currFilter.notValues = notValues.length > 0 ? notValues : undefined; + currFilter.values = values.length > 0 ? values : undefined; + + currFilter.wildcards = wildcards; + currFilter.notWildcards = notWildcards; + + const otherFilters = filters.filter(({ field: fd }) => fd !== field); + + if (concat(values, notValues, wildcards, notWildcards).length > 0) { + setSeries(seriesId, { ...series, filters: [...otherFilters, currFilter] }); + } else { + setSeries(seriesId, { ...series, filters: otherFilters }); + } + }; + + const removeFilter = ({ field, value, negate, isWildcard }: UpdateFilter) => { + const filtersN = filters + .map((filter) => { + if (filter.field === field) { + if (negate) { + if (isWildcard) { + const notWildcardsN = filter.notWildcards?.filter((val) => + value instanceof Array ? !value.includes(val) : val !== value + ); + return { ...filter, notWildcards: notWildcardsN }; + } + const notValuesN = filter.notValues?.filter((val) => + value instanceof Array ? !value.includes(val) : val !== value + ); + return { ...filter, notValues: notValuesN }; + } else { + if (isWildcard) { + const wildcardsN = filter.wildcards?.filter((val) => + value instanceof Array ? !value.includes(val) : val !== value + ); + return { ...filter, wildcards: wildcardsN }; + } + const valuesN = filter.values?.filter((val) => + value instanceof Array ? !value.includes(val) : val !== value + ); + return { ...filter, values: valuesN }; + } + } + + return filter; + }) + .filter( + ({ values = [], notValues = [], wildcards = [], notWildcards = [] }) => + values.length > 0 || + notValues.length > 0 || + wildcards.length > 0 || + notWildcards.length > 0 + ); + setSeries(seriesId, { ...series, filters: filtersN }); + }; + + const addFilter = ({ field, value, negate }: UpdateFilter) => { + const currFilter: UrlFilter = { field }; + if (negate) { + currFilter.notValues = value instanceof Array ? value : [value]; + } else { + currFilter.values = value instanceof Array ? value : [value]; + } + + if (filters.length === 0) { + setSeries(seriesId, { ...series, filters: [currFilter] }); + } else { + setSeries(seriesId, { + ...series, + filters: [currFilter, ...filters.filter((ft) => ft.field !== field)], + }); + } + }; + + const updateFilter = ({ field, value, negate, wildcards }: UpdateFilter) => { + const currFilter: UrlFilter | undefined = filters.find(({ field: fd }) => field === fd) ?? { + field, + }; + + const currNotValues = currFilter.notValues ?? []; + const currValues = currFilter.values ?? []; + + const notValues = currNotValues.filter((val) => + value instanceof Array ? !value.includes(val) : val !== value + ); + + const values = currValues.filter((val) => + value instanceof Array ? !value.includes(val) : val !== value + ); + + if (negate) { + if (value instanceof Array) { + notValues.push(...value); + } else { + notValues.push(value); + } + } else { + if (value instanceof Array) { + values.push(...value); + } else { + values.push(value); + } + } + + replaceFilter({ field, values, notValues, wildcards }); + }; + + const setFilter = ({ field, value, negate, wildcards }: UpdateFilter) => { + const currFilter: UrlFilter | undefined = filters.find(({ field: fd }) => field === fd); + + if (!currFilter) { + addFilter({ field, value, negate, wildcards }); + } else { + updateFilter({ field, value, negate, wildcards }); + } + }; + + const setFiltersWildcard = ({ field, wildcards }: { field: string; wildcards: string[] }) => { + let currFilter: UrlFilter | undefined = filters.find(({ field: fd }) => field === fd); + + if (!currFilter) { + currFilter = { field, wildcards }; + + if (filters.length === 0) { + setSeries(seriesId, { ...series, filters: [currFilter] }); + } else { + setSeries(seriesId, { + ...series, + filters: [currFilter, ...filters.filter((ft) => ft.field !== field)], + }); + } + } else { + replaceFilter({ + field, + values: currFilter.values ?? [], + notValues: currFilter.notValues ?? [], + wildcards, + }); + } + }; + + const invertFilter = ({ field, value, negate }: UpdateFilter) => { + updateFilter({ field, value, negate: !negate }); + }; + + return { invertFilter, setFilter, removeFilter, replaceFilter, setFiltersWildcard }; +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_series_storage.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_series_storage.test.tsx new file mode 100644 index 0000000000000..465fc4c88d9e9 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_series_storage.test.tsx @@ -0,0 +1,291 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { Router } from 'react-router-dom'; +import { Route } from '@kbn/shared-ux-router'; +import { render } from '@testing-library/react'; +import { UrlStorageContextProvider, useSeriesStorage, reportTypeKey } from './use_series_storage'; +import { getHistoryFromUrl } from '../rtl_helpers'; +import type { AppDataType } from '../types'; +import { ReportTypes } from '../configurations/constants'; +import * as useTrackMetric from '../../../../hooks/use_track_metric'; + +const mockSingleSeries = [ + { + name: 'performance-distribution', + dataType: 'ux' as AppDataType, + breakdown: 'user_agent.name', + time: { from: 'now-15m', to: 'now' }, + }, +]; + +const mockMultipleSeries = [ + { + name: 'performance-distribution', + dataType: 'ux' as AppDataType, + breakdown: 'user_agent.name', + time: { from: 'now-15m', to: 'now' }, + filters: [ + { + field: 'url.full', + value: 'https://elastic.co', + }, + ], + selectedMetricField: 'transaction.duration.us', + }, + { + name: 'kpi-over-time', + dataType: 'synthetics' as AppDataType, + breakdown: 'user_agent.name', + time: { from: 'now-15m', to: 'now' }, + filters: [ + { + field: 'monitor.type', + value: 'browser', + }, + ], + selectedMetricField: 'monitor.duration.us', + }, +]; + +describe('userSeriesStorage', function () { + function setupTestComponent(seriesData: any) { + const setData = jest.fn(); + + function TestComponent() { + const data = useSeriesStorage(); + + useEffect(() => { + setData(data); + }, [data]); + + return Test; + } + + render( + + + (key === 'sr' ? seriesData : null)), + set: jest.fn(), + }} + > + + + + + ); + + return setData; + } + it('should return expected result when there is one series', function () { + const setData = setupTestComponent(mockSingleSeries); + + expect(setData).toHaveBeenCalledTimes(2); + expect(setData).toHaveBeenLastCalledWith( + expect.objectContaining({ + allSeries: [ + { + name: 'performance-distribution', + dataType: 'ux', + breakdown: 'user_agent.name', + time: { from: 'now-15m', to: 'now' }, + }, + ], + firstSeries: { + name: 'performance-distribution', + dataType: 'ux', + breakdown: 'user_agent.name', + time: { from: 'now-15m', to: 'now' }, + }, + }) + ); + }); + + it('should return expected result when there are multiple series', function () { + const setData = setupTestComponent(mockMultipleSeries); + + expect(setData).toHaveBeenCalledTimes(2); + expect(setData).toHaveBeenLastCalledWith( + expect.objectContaining({ + allSeries: mockMultipleSeries, + firstSeries: mockMultipleSeries[0], + }) + ); + }); + + it('should return expected result when there are no series', function () { + const setData = setupTestComponent([]); + + expect(setData).toHaveBeenCalledTimes(1); + expect(setData).toHaveBeenLastCalledWith( + expect.objectContaining({ + allSeries: [], + firstSeries: undefined, + }) + ); + }); + + it('ensures that only one series has a breakdown', () => { + function wrapper({ children }: { children: React.ReactElement }) { + return ( + (key === 'sr' ? mockMultipleSeries : null)), + set: jest.fn(), + }} + > + {children} + + ); + } + const { result } = renderHook(() => useSeriesStorage(), { wrapper }); + + act(() => { + result.current.setSeries(1, mockMultipleSeries[1]); + }); + + expect(result.current.allSeries).toEqual([ + mockMultipleSeries[0], + { + ...mockMultipleSeries[1], + breakdown: undefined, + }, + ]); + }); + + it('sets reportType when calling applyChanges', () => { + const setStorage = jest.fn(); + function wrapper({ children }: { children: React.ReactElement }) { + return ( + + key === 'sr' ? mockMultipleSeries : 'kpi-over-time' + ), + set: setStorage, + }} + > + {children} + + ); + } + const { result } = renderHook(() => useSeriesStorage(), { wrapper }); + + act(() => { + result.current.setReportType(ReportTypes.DISTRIBUTION); + }); + + act(() => { + result.current.applyChanges(); + }); + + expect(setStorage).toBeCalledWith(reportTypeKey, ReportTypes.DISTRIBUTION); + }); + + it('returns reportType in state, not url storage, from hook', () => { + const setStorage = jest.fn(); + function wrapper({ children }: { children: React.ReactElement }) { + return ( + + key === 'sr' ? mockMultipleSeries : 'kpi-over-time' + ), + set: setStorage, + }} + > + {children} + + ); + } + const { result } = renderHook(() => useSeriesStorage(), { wrapper }); + + act(() => { + result.current.setReportType(ReportTypes.DISTRIBUTION); + }); + + expect(result.current.reportType).toEqual(ReportTypes.DISTRIBUTION); + }); + + it('ensures that telemetry is called', () => { + const trackEvent = jest.fn(); + jest.spyOn(useTrackMetric, 'useUiTracker').mockReturnValue(trackEvent); + function wrapper({ children }: { children: React.ReactElement }) { + return ( + + key === 'sr' ? mockMultipleSeries : 'kpi-over-time' + ), + set: jest.fn(), + }} + > + {children} + + ); + } + const { result } = renderHook(() => useSeriesStorage(), { wrapper }); + + act(() => { + result.current.applyChanges(); + }); + + expect(trackEvent).toBeCalledTimes(7); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: 'exploratory_view__filters__filter_url.full', + metricType: 'count', + }); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: 'exploratory_view__filters__filter_monitor.type', + metricType: 'count', + }); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: 'exploratory_view__filters__report_type_kpi-over-time__data_type_ux__filter_url.full', + metricType: 'count', + }); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: + 'exploratory_view__filters__report_type_kpi-over-time__data_type_synthetics__filter_monitor.type', + metricType: 'count', + }); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: + 'exploratory_view__report_type_kpi-over-time__data_type_synthetics__metric_type_monitor.duration.us', + metricType: 'count', + }); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: + 'exploratory_view__report_type_kpi-over-time__data_type_ux__metric_type_transaction.duration.us', + metricType: 'count', + }); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: 'exploratory_view_apply_changes', + metricType: 'count', + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_series_storage.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_series_storage.tsx new file mode 100644 index 0000000000000..9cbddbbde9965 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_series_storage.tsx @@ -0,0 +1,194 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { IKbnUrlStateStorage, ISessionStorageStateStorage } from '@kbn/kibana-utils-plugin/public'; +import { OperationType, SeriesType } from '@kbn/lens-plugin/public'; +import { ChartTimeRange } from '../header/last_updated'; +import { useUiTracker } from '../../../../hooks/use_track_metric'; +import type { + AppDataType, + ReportViewType, + SeriesUrl, + UrlFilter, + URLReportDefinition, +} from '../types'; +import { convertToShortUrl } from '../configurations/exploratory_view_url'; +import { URL_KEYS } from '../configurations/constants/url_constants'; +import { trackTelemetryOnApply } from '../utils/telemetry'; + +export interface SeriesContextValue { + firstSeries?: SeriesUrl; + lastRefresh: number; + setLastRefresh: (val: number) => void; + applyChanges: (onApply?: () => void) => void; + allSeries: AllSeries; + setSeries: (seriesIndex: number, newValue: SeriesUrl) => void; + getSeries: (seriesIndex: number) => SeriesUrl | undefined; + removeSeries: (seriesIndex: number) => void; + setReportType: (reportType: ReportViewType) => void; + storage: IKbnUrlStateStorage | ISessionStorageStateStorage; + reportType: ReportViewType; + chartTimeRangeContext?: ChartTimeRange; + setChartTimeRangeContext: React.Dispatch>; +} +export const UrlStorageContext = createContext({} as SeriesContextValue); + +interface ProviderProps { + storage: IKbnUrlStateStorage | ISessionStorageStateStorage; +} + +export function convertAllShortSeries(allShortSeries: AllShortSeries) { + return (allShortSeries ?? []).map((shortSeries) => convertFromShortUrl(shortSeries)); +} + +export const allSeriesKey = 'sr'; +export const reportTypeKey = 'reportType'; + +export function UrlStorageContextProvider({ + children, + storage, +}: ProviderProps & { children: JSX.Element }) { + const [allSeries, setAllSeries] = useState(() => + convertAllShortSeries(storage.get(allSeriesKey) ?? []) + ); + + const [lastRefresh, setLastRefresh] = useState(() => Date.now()); + + const [chartTimeRangeContext, setChartTimeRangeContext] = useState(); + + const [reportType, setReportType] = useState( + () => ((storage as IKbnUrlStateStorage).get(reportTypeKey) ?? '') as ReportViewType + ); + + const [firstSeries, setFirstSeries] = useState(); + + const trackEvent = useUiTracker(); + + useEffect(() => { + const firstSeriesT = allSeries?.[0]; + + setFirstSeries(firstSeriesT); + }, [allSeries, storage]); + + const setSeries = useCallback((seriesIndex: number, newValue: SeriesUrl) => { + setAllSeries((prevAllSeries) => { + const seriesWithCurrentBreakdown = prevAllSeries.findIndex((series) => series.breakdown); + const newStateRest = prevAllSeries.map((series, index) => { + if (index === seriesIndex) { + return { + ...newValue, + breakdown: + seriesWithCurrentBreakdown === seriesIndex || seriesWithCurrentBreakdown === -1 + ? newValue.breakdown + : undefined, + }; + } + return series; + }); + + if (prevAllSeries.length === seriesIndex) { + return [...newStateRest, newValue]; + } + + return [...newStateRest]; + }); + }, []); + + const removeSeries = useCallback((seriesIndex: number) => { + setAllSeries((prevAllSeries) => + prevAllSeries.filter((seriesT, index) => index !== seriesIndex) + ); + }, []); + + const getSeries = useCallback( + (seriesIndex: number) => { + return allSeries[seriesIndex]; + }, + [allSeries] + ); + + const applyChanges = useCallback( + (onApply?: () => void) => { + const allShortSeries = allSeries.map((series) => convertToShortUrl(series)); + (storage as IKbnUrlStateStorage).set(reportTypeKey, reportType); + + (storage as IKbnUrlStateStorage).set(allSeriesKey, allShortSeries); + setLastRefresh(Date.now()); + + trackTelemetryOnApply(trackEvent, allSeries, reportType); + + if (onApply) { + onApply(); + } + }, + [allSeries, storage, trackEvent, reportType] + ); + + const value = { + applyChanges, + storage, + getSeries, + setSeries, + removeSeries, + allSeries, + lastRefresh, + setLastRefresh, + setReportType, + reportType, + chartTimeRangeContext, + setChartTimeRangeContext, + firstSeries: firstSeries!, + }; + return {children}; +} + +export function useSeriesStorage() { + return useContext(UrlStorageContext); +} + +function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { + const { dt, op, st, bd, ft, time, rdf, mt, h, n, c, spa, ...restSeries } = newValue; + return { + operationType: op, + seriesType: st, + breakdown: bd, + filters: ft!, + time: time!, + reportDefinitions: rdf, + dataType: dt!, + selectedMetricField: mt, + hidden: h, + name: n, + color: c, + showPercentileAnnotations: spa, + ...restSeries, + }; +} + +interface ShortUrlSeries { + [URL_KEYS.OPERATION_TYPE]?: OperationType; + [URL_KEYS.DATA_TYPE]?: AppDataType; + [URL_KEYS.SERIES_TYPE]?: SeriesType; + [URL_KEYS.BREAK_DOWN]?: string; + [URL_KEYS.FILTERS]?: UrlFilter[]; + [URL_KEYS.REPORT_DEFINITIONS]?: URLReportDefinition; + [URL_KEYS.SELECTED_METRIC]?: string; + [URL_KEYS.HIDDEN]?: boolean; + [URL_KEYS.NAME]: string; + [URL_KEYS.COLOR]?: string; + [URL_KEYS.SHOW_PERCENTILE_ANNOTATIONS]?: boolean; + time?: { + to: string; + from: string; + }; +} + +export type AllShortSeries = ShortUrlSeries[]; +export type AllSeries = SeriesUrl[]; + +export const NEW_SERIES_KEY = 'new-series'; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_time_range.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_time_range.test.tsx new file mode 100644 index 0000000000000..b97e3cc3c6923 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_time_range.test.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { allSeriesKey, reportTypeKey, UrlStorageContextProvider } from './use_series_storage'; +import { renderHook } from '@testing-library/react-hooks'; +import { useExpViewTimeRange } from './use_time_range'; +import { ReportTypes } from '../configurations/constants'; +import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; +import { + TRANSACTION_DURATION, + METRIC_SYSTEM_MEMORY_USAGE, +} from '../configurations/constants/elasticsearch_fieldnames'; + +const mockSingleSeries = [ + { + name: 'performance-distribution', + dataType: 'ux', + breakdown: 'user_agent.name', + time: { from: 'now-15m', to: 'now' }, + selectedMetricField: TRANSACTION_DURATION, + reportDefinitions: { 'service.name': ['elastic-co'] }, + }, +]; + +const mockMultipleSeries = [ + ...mockSingleSeries, + { + name: 'kpi-over-time', + dataType: 'synthetics', + breakdown: 'user_agent.name', + time: { from: 'now-30m', to: 'now' }, + selectedMetricField: TRANSACTION_DURATION, + reportDefinitions: { 'service.name': ['elastic-co'] }, + }, +]; + +describe('useExpViewTimeRange', function () { + const storage = createKbnUrlStateStorage({ useHash: false }); + + function Wrapper({ children }: { children: JSX.Element }) { + return {children}; + } + it('should return expected result when there is one series', async function () { + await storage.set(allSeriesKey, mockSingleSeries); + await storage.set(reportTypeKey, ReportTypes.KPI); + + const { result } = renderHook(() => useExpViewTimeRange(), { + wrapper: Wrapper, + }); + + expect(result.current).toEqual({ + from: 'now-15m', + to: 'now', + }); + }); + + it('should return expected result when there are multiple KPI series', async function () { + await storage.set(allSeriesKey, mockMultipleSeries); + await storage.set(reportTypeKey, ReportTypes.KPI); + + const { result } = renderHook(() => useExpViewTimeRange(), { + wrapper: Wrapper, + }); + + expect(result.current).toEqual({ + from: 'now-15m', + to: 'now', + }); + }); + + it('should return expected result when there are multiple distribution series with relative dates', async function () { + await storage.set(allSeriesKey, mockMultipleSeries); + await storage.set(reportTypeKey, ReportTypes.DISTRIBUTION); + + const { result } = renderHook(() => useExpViewTimeRange(), { + wrapper: Wrapper, + }); + + expect(result.current).toEqual({ + from: 'now-30m', + to: 'now', + }); + }); + + it("should correctly parse dates when last series doesn't have a report definition", async function () { + const mockSeriesWithoutDefinitions = [ + ...mockSingleSeries, + { + dataType: 'mobile', + name: 'mobile-series-1', + reportDefinitions: undefined, + selectedMetricField: METRIC_SYSTEM_MEMORY_USAGE, + time: { from: 'now-30m', to: 'now' }, + }, + ]; + + await storage.set(allSeriesKey, mockSeriesWithoutDefinitions); + await storage.set(reportTypeKey, ReportTypes.DISTRIBUTION); + + const { result } = renderHook(() => useExpViewTimeRange(), { + wrapper: Wrapper, + }); + + expect(result.current).toEqual({ + from: 'now-30m', + to: 'now', + }); + }); + + it('should return expected result when there are multiple distribution series with absolute dates', async function () { + // from:'2021-10-11T09:55:39.551Z',to:'2021-10-11T10:55:41.516Z'))) + mockMultipleSeries[0].time.from = '2021-10-11T09:55:39.551Z'; + mockMultipleSeries[0].time.to = '2021-10-11T11:55:41.516Z'; + + mockMultipleSeries[1].time.from = '2021-01-11T09:55:39.551Z'; + mockMultipleSeries[1].time.to = '2021-10-11T10:55:41.516Z'; + + await storage.set(allSeriesKey, mockMultipleSeries); + await storage.set(reportTypeKey, ReportTypes.DISTRIBUTION); + + const { result } = renderHook(() => useExpViewTimeRange(), { + wrapper: Wrapper, + }); + + expect(result.current).toEqual({ + from: '2021-01-11T09:55:39.551Z', + to: '2021-10-11T11:55:41.516Z', + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_time_range.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_time_range.ts new file mode 100644 index 0000000000000..7d6203bef2df8 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/hooks/use_time_range.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { + AllSeries, + allSeriesKey, + convertAllShortSeries, + useSeriesStorage, +} from './use_series_storage'; + +import { ReportViewType, SeriesUrl } from '../types'; +import { ReportTypes } from '../configurations/constants'; +import { parseRelativeDate } from '../components/date_range_picker'; + +export const combineTimeRanges = ( + reportType: ReportViewType, + allSeries: SeriesUrl[], + firstSeries?: SeriesUrl +) => { + let to: string = ''; + let from: string = ''; + + if (reportType === ReportTypes.KPI) { + return firstSeries?.time; + } + + allSeries.forEach((series) => { + if (series.dataType && series.selectedMetricField && series.time) { + const seriesFrom = parseRelativeDate(series.time.from)!; + const seriesTo = parseRelativeDate(series.time.to, { roundUp: true })!; + + if (!to || seriesTo > parseRelativeDate(to, { roundUp: true })) { + to = series.time.to; + } + if (!from || seriesFrom < parseRelativeDate(from)) { + from = series.time.from; + } + } + }); + + return { to, from }; +}; +export const useExpViewTimeRange = () => { + const { storage, reportType, lastRefresh, firstSeries } = useSeriesStorage(); + + return useMemo(() => { + // we only use the data from url to apply, since that get updated to apply changes + const allSeriesFromUrl: AllSeries = convertAllShortSeries(storage.get(allSeriesKey) ?? []); + const firstSeriesT = allSeriesFromUrl?.[0]; + + return firstSeriesT ? combineTimeRanges(reportType, allSeriesFromUrl, firstSeriesT) : undefined; + // we want to keep last refresh in dependencies to force refresh + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reportType, storage, lastRefresh, firstSeries]); +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/index.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/index.tsx new file mode 100644 index 0000000000000..9e6ac7d0394e3 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/index.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { useHistory } from 'react-router-dom'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { + createKbnUrlStateStorage, + withNotifyOnErrors, + createSessionStorageStateStorage, +} from '@kbn/kibana-utils-plugin/public'; +import { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import { useTrackPageview } from '@kbn/observability-plugin/public'; +import { ExploratoryView } from './exploratory_view'; +import { ExploratoryViewPublicPluginsStart } from '../../../plugin'; +import { useBreadcrumbs } from '../../../hooks/use_breadcrumbs'; +import { DataViewContextProvider } from './hooks/use_app_data_view'; +import { UrlStorageContextProvider } from './hooks/use_series_storage'; +import { RefreshButton } from './header/refresh_button'; + +const PAGE_TITLE = i18n.translate('xpack.exploratoryView.expView.heading.label', { + defaultMessage: 'Explore data', +}); + +export interface ExploratoryViewPageProps { + useSessionStorage?: boolean; + saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void; + app?: { id: string; label: string }; +} + +export function ExploratoryViewPage({ + app, + saveAttributes, + useSessionStorage = false, +}: ExploratoryViewPageProps) { + const { + services: { uiSettings, notifications, observability }, + } = useKibana(); + + const history = useHistory(); + + const ObservabilityPageTemplate = observability.navigation.PageTemplate; + + useTrackPageview({ app: 'observability-overview', path: 'exploratory-view' }); + useTrackPageview({ + app: 'observability-overview', + path: 'exploratory-view', + delay: 15000, + }); + + useBreadcrumbs( + [ + { + text: i18n.translate('xpack.exploratoryView.overview.exploratoryView', { + defaultMessage: 'Explore data', + }), + }, + ], + app + ); + + const kbnUrlStateStorage = useSessionStorage + ? createSessionStorageStateStorage() + : createKbnUrlStateStorage({ + history, + useHash: uiSettings!.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(notifications!.toasts), + }); + + return ( + + ], + }} + > + + + + + + ); +} + +// eslint-disable-next-line import/no-default-export +export default ExploratoryViewPage; +export { DataTypes } from './labels'; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/labels.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/labels.ts new file mode 100644 index 0000000000000..935756c496515 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/labels.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; + +export enum DataTypes { + SYNTHETICS = 'synthetics', + UPTIME = 'uptime', + UX = 'ux', + MOBILE = 'mobile', + METRICS = 'infra_metrics', + LOGS = 'infra_logs', + ALERTS = 'alerts', +} + +export const DataTypesLabels: Record = { + [DataTypes.UX]: i18n.translate('xpack.exploratoryView.overview.exploratoryView.uxLabel', { + defaultMessage: 'User experience (RUM)', + }), + + [DataTypes.SYNTHETICS]: i18n.translate( + 'xpack.exploratoryView.overview.exploratoryView.syntheticsLabel', + { + defaultMessage: 'Synthetics monitoring', + } + ), + + [DataTypes.UPTIME]: i18n.translate('xpack.exploratoryView.overview.exploratoryView.uptimeLabel', { + defaultMessage: 'Uptime', + }), + + [DataTypes.METRICS]: i18n.translate( + 'xpack.exploratoryView.overview.exploratoryView.metricsLabel', + { + defaultMessage: 'Metrics', + } + ), + + [DataTypes.LOGS]: i18n.translate('xpack.exploratoryView.overview.exploratoryView.logsLabel', { + defaultMessage: 'Logs', + }), + + [DataTypes.MOBILE]: i18n.translate( + 'xpack.exploratoryView.overview.exploratoryView.mobileExperienceLabel', + { + defaultMessage: 'Mobile experience', + } + ), + [DataTypes.ALERTS]: i18n.translate('xpack.exploratoryView.overview.exploratoryView.alertsLabel', { + defaultMessage: 'Alerts', + }), +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/lens_embeddable.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/lens_embeddable.tsx new file mode 100644 index 0000000000000..bed9952b0ac13 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/lens_embeddable.tsx @@ -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 { i18n } from '@kbn/i18n'; +import React, { Dispatch, SetStateAction, useCallback, useState } from 'react'; +import styled from 'styled-components'; +import { LensEmbeddableInput, TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useUiTracker } from '../../../hooks/use_track_metric'; +import { useSeriesStorage } from './hooks/use_series_storage'; +import { ExploratoryViewPublicPluginsStart } from '../../../plugin'; +import { useExpViewTimeRange } from './hooks/use_time_range'; +import { parseRelativeDate } from './components/date_range_picker'; +import { trackTelemetryOnLoad } from './utils/telemetry'; +import type { ChartTimeRange } from './header/last_updated'; + +interface Props { + lensAttributes: TypedLensByValueInput['attributes']; + setChartTimeRangeContext: Dispatch>; +} +const EXECUTION_CONTEXT = { + type: 'observability_exploratory_view', +}; + +export function LensEmbeddable(props: Props) { + const { lensAttributes, setChartTimeRangeContext } = props; + const { + services: { lens, notifications }, + } = useKibana(); + + const LensComponent = lens?.EmbeddableComponent; + const LensSaveModalComponent = lens?.SaveModalComponent; + + const { firstSeries, setSeries, reportType, lastRefresh } = useSeriesStorage(); + + const [isSaveOpen, setIsSaveOpen] = useState(false); + + const firstSeriesId = 0; + + const timeRange = useExpViewTimeRange(); + + const trackEvent = useUiTracker(); + + const onLensLoad = useCallback( + (isLoading) => { + const timeLoaded = Date.now(); + + setChartTimeRangeContext?.({ + lastUpdated: timeLoaded, + to: parseRelativeDate(timeRange?.to || '')?.valueOf(), + from: parseRelativeDate(timeRange?.from || '')?.valueOf(), + }); + + if (!isLoading) { + trackTelemetryOnLoad(trackEvent, lastRefresh, timeLoaded); + } + }, + [setChartTimeRangeContext, timeRange, lastRefresh, trackEvent] + ); + + const onBrushEnd = useCallback( + ({ range }: { range: number[] }) => { + if (reportType !== 'data-distribution' && firstSeries) { + setSeries(firstSeriesId, { + ...firstSeries, + time: { + from: new Date(range[0]).toISOString(), + to: new Date(range[1]).toISOString(), + }, + }); + } else { + notifications?.toasts.add( + i18n.translate('xpack.exploratoryView.exploratoryView.noBrusing', { + defaultMessage: 'Zoom by brush selection is only available on time series charts.', + }) + ); + } + }, + [reportType, setSeries, firstSeries, notifications?.toasts] + ); + + if (!timeRange || !lensAttributes) { + return null; + } + + return ( + + + {isSaveOpen && lensAttributes && ( + setIsSaveOpen(false)} + // if we want to do anything after the viz is saved + // right now there is no action, so an empty function + onSave={() => {}} + /> + )} + + ); +} + +const LensWrapper = styled.div` + height: 100%; + + .embPanel__optionsMenuPopover { + visibility: collapse; + } + + &&&:hover { + .embPanel__optionsMenuPopover { + visibility: visible; + } + } + + && .embPanel--editing { + border-style: initial !important; + :hover { + box-shadow: none; + } + } + .embPanel__title { + display: none; + } + + &&& > div { + height: 100%; + } +`; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/obsv_exploratory_view.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/obsv_exploratory_view.tsx new file mode 100644 index 0000000000000..99112e46eece8 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/obsv_exploratory_view.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as React from 'react'; +import { EuiErrorBoundary } from '@elastic/eui'; +import { getAlertsSingleMetricConfig } from './configurations/alerts_configs/single_metric_config'; +import { getAlertsKPIConfig } from './configurations/alerts_configs/kpi_over_time_config'; +import { DataTypes, DataTypesLabels } from './labels'; +import { getSyntheticsHeatmapConfig } from './configurations/synthetics/heatmap_config'; +import { getSyntheticsSingleMetricConfig } from './configurations/synthetics/single_metric_config'; +import { ExploratoryViewPage } from '.'; +import { ExploratoryViewContextProvider } from './contexts/exploratory_view_config'; +import { AppDataType, ReportViewType } from './types'; + +import { + CORE_WEB_VITALS_LABEL, + DEVICE_DISTRIBUTION_LABEL, + HEATMAP_LABEL, + KPI_OVER_TIME_LABEL, + PERF_DIST_LABEL, + SINGLE_METRIC_LABEL, +} from './configurations/constants/labels'; +import { SELECT_REPORT_TYPE } from './series_editor/series_editor'; +import { getRumDistributionConfig } from './configurations/rum/data_distribution_config'; +import { getKPITrendsLensConfig } from './configurations/rum/kpi_over_time_config'; +import { getCoreWebVitalsConfig } from './configurations/rum/core_web_vitals_config'; +import { getSyntheticsKPIConfig } from './configurations/synthetics/kpi_over_time_config'; +import { getSyntheticsDistributionConfig } from './configurations/synthetics/data_distribution_config'; +import { getMobileKPIDistributionConfig } from './configurations/mobile/distribution_config'; +import { getMobileKPIConfig } from './configurations/mobile/kpi_over_time_config'; +import { getMobileDeviceDistributionConfig } from './configurations/mobile/device_distribution_config'; +import { usePluginContext } from '../../../hooks/use_plugin_context'; +import { getLogsKPIConfig } from './configurations/infra_logs/kpi_over_time_config'; +import { getSingleMetricConfig } from './configurations/rum/single_metric_config'; + +export const dataTypes: Array<{ id: AppDataType; label: string }> = [ + { + id: DataTypes.UPTIME, + label: DataTypesLabels[DataTypes.UPTIME], + }, + { + id: DataTypes.SYNTHETICS, + label: DataTypesLabels[DataTypes.SYNTHETICS], + }, + { + id: DataTypes.UX, + label: DataTypesLabels[DataTypes.UX], + }, + { + id: DataTypes.LOGS, + label: DataTypesLabels[DataTypes.LOGS], + }, + { + id: DataTypes.MOBILE, + label: DataTypesLabels[DataTypes.MOBILE], + }, + { + id: DataTypes.ALERTS, + label: DataTypesLabels[DataTypes.ALERTS], + }, +]; + +export const reportTypesList: Array<{ + reportType: ReportViewType | typeof SELECT_REPORT_TYPE; + label: string; +}> = [ + { reportType: 'kpi-over-time', label: KPI_OVER_TIME_LABEL }, + { reportType: 'data-distribution', label: PERF_DIST_LABEL }, + { reportType: 'core-web-vitals', label: CORE_WEB_VITALS_LABEL }, + { reportType: 'device-data-distribution', label: DEVICE_DISTRIBUTION_LABEL }, + { reportType: 'single-metric', label: SINGLE_METRIC_LABEL }, + { reportType: 'heatmap', label: HEATMAP_LABEL }, +]; + +export const obsvReportConfigMap = { + [DataTypes.UX]: [ + getKPITrendsLensConfig, + getRumDistributionConfig, + getCoreWebVitalsConfig, + getSingleMetricConfig, + ], + [DataTypes.SYNTHETICS]: [ + getSyntheticsKPIConfig, + getSyntheticsDistributionConfig, + getSyntheticsSingleMetricConfig, + getSyntheticsHeatmapConfig, + ], + [DataTypes.UPTIME]: [ + getSyntheticsKPIConfig, + getSyntheticsDistributionConfig, + getSyntheticsSingleMetricConfig, + getSyntheticsHeatmapConfig, + ], + [DataTypes.MOBILE]: [ + getMobileKPIConfig, + getMobileKPIDistributionConfig, + getMobileDeviceDistributionConfig, + ], + [DataTypes.LOGS]: [getLogsKPIConfig], + [DataTypes.ALERTS]: [getAlertsKPIConfig, getAlertsSingleMetricConfig], +}; + +export function ObservabilityExploratoryView() { + const { appMountParameters } = usePluginContext(); + + return ( + + + + + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/rtl_helpers.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/rtl_helpers.tsx new file mode 100644 index 0000000000000..981062926d78d --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/rtl_helpers.tsx @@ -0,0 +1,398 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { of } from 'rxjs'; +import React, { ReactElement } from 'react'; +import { stringify } from 'query-string'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { + render as reactTestLibRender, + RenderOptions, + MatcherFunction, +} from '@testing-library/react'; +import { Router } from 'react-router-dom'; +import { Route } from '@kbn/shared-ux-router'; +import { createMemoryHistory, History } from 'history'; +import { CoreStart } from '@kbn/core/public'; +import { I18nProvider } from '@kbn/i18n-react'; +import { coreMock, themeServiceMock } from '@kbn/core/public/mocks'; +import { KibanaContextProvider, KibanaServices } from '@kbn/kibana-react-plugin/public'; +import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; +import { lensPluginMock } from '@kbn/lens-plugin/public/mocks'; +import { setIndexPatterns } from '@kbn/unified-search-plugin/public/services'; +import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public'; +import { createStubDataView } from '@kbn/data-views-plugin/common/stubs'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { casesPluginMock } from '@kbn/cases-plugin/public/mocks'; +import { DataViewSpec } from '@kbn/data-views-plugin/public'; +import { rumFieldFormats } from './configurations/rum/field_formats'; +import { ExploratoryViewPublicPluginsStart } from '../../../plugin'; +import * as useAppDataViewHook from './hooks/use_app_data_view'; +import { DataViewContext, DataViewContextProvider } from './hooks/use_app_data_view'; +import { + AllSeries, + reportTypeKey, + SeriesContextValue, + UrlStorageContext, +} from './hooks/use_series_storage'; + +import * as fetcherHook from '../../../hooks/use_fetcher'; +import * as useSeriesFilterHook from './hooks/use_series_filters'; +import * as useHasDataHook from '../../../hooks/use_has_data'; +import * as useValuesListHook from '../../../hooks/use_values_list'; + +import dataViewData from './configurations/test_data/test_data_view.json'; + +import { AppDataType, SeriesUrl, UrlFilter } from './types'; +import { ListItem } from '../../../hooks/use_values_list'; +import { TRANSACTION_DURATION } from './configurations/constants/elasticsearch_fieldnames'; +import { dataTypes, obsvReportConfigMap, reportTypesList } from './obsv_exploratory_view'; +import { ExploratoryViewContextProvider } from './contexts/exploratory_view_config'; + +interface KibanaProps { + services?: KibanaServices; +} + +export interface KibanaProviderOptions { + core?: ExtraCore & Partial; + kibanaProps?: KibanaProps; +} + +interface MockKibanaProviderProps> + extends KibanaProviderOptions { + children: ReactElement; + history: History; +} + +type MockRouterProps> = MockKibanaProviderProps; + +type Url = + | string + | { + path: string; + queryParams: Record; + }; + +interface RenderRouterOptions extends KibanaProviderOptions { + history?: History; + renderOptions?: Omit; + url?: Url; + initSeries?: { + data?: AllSeries; + filters?: UrlFilter[]; + breakdown?: string; + }; +} + +function getSetting(key: string): T { + if (key === 'timepicker:quickRanges') { + return [ + { + display: 'Today', + from: 'now/d', + to: 'now/d', + }, + ] as unknown as T; + } + return 'MMM D, YYYY @ HH:mm:ss.SSS' as unknown as T; +} + +function setSetting$(key: string): T { + return of('MMM D, YYYY @ HH:mm:ss.SSS') as unknown as T; +} + +/* default mock core */ +const defaultCore = coreMock.createStart(); +export const mockCore: () => Partial = () => { + const core: Partial = { + ...defaultCore, + application: { + ...defaultCore.application, + getUrlForApp: () => '/app/exploratory-view', + navigateToUrl: jest.fn(), + capabilities: { + ...defaultCore.application.capabilities, + observability: { + 'alerting:save': true, + configureSettings: true, + save: true, + show: true, + }, + }, + }, + uiSettings: { + ...defaultCore.uiSettings, + get: getSetting, + get$: setSetting$, + }, + lens: lensPluginMock.createStartContract(), + data: dataPluginMock.createStartContract(), + dataViews: dataViewPluginMocks.createStartContract(), + cases: casesPluginMock.createStartContract(), + }; + + return core; +}; + +/* Mock Provider Components */ +export function MockKibanaProvider>({ + children, + core, + kibanaProps, +}: MockKibanaProviderProps) { + const dataView = mockDataView; + setIndexPatterns({ + ...[dataView], + get: async () => dataView, + } as unknown as DataViewsContract); + + return ( + + + + {children} + + + + ); +} + +export function MockRouter>({ + children, + core, + history = createMemoryHistory(), + kibanaProps, +}: MockRouterProps) { + return ( + + + + {children} + + + + ); +} + +/* Custom react testing library render */ +export function render( + ui: ReactElement, + { + history = createMemoryHistory(), + core: customCore, + kibanaProps, + renderOptions, + url = '/app/exploratory-view/', + initSeries = {}, + }: RenderRouterOptions = {} +): any { + if (url) { + history = getHistoryFromUrl(url); + } + + const core = { + ...mockCore(), + ...customCore, + }; + + const seriesContextValue = mockSeriesStorageContext(initSeries); + + return { + ...reactTestLibRender( + + + + {ui} + + + , + renderOptions + ), + history, + core, + ...seriesContextValue, + }; +} + +export const getHistoryFromUrl = (url: Url) => { + if (typeof url === 'string') { + return createMemoryHistory({ + initialEntries: [url], + }); + } + + return createMemoryHistory({ + initialEntries: [url.path + stringify(url.queryParams)], + }); +}; + +export const mockFetcher = (data: any) => { + return jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ + data, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }); +}; + +export const mockUseHasData = () => { + const onRefreshTimeRange = jest.fn(); + const spy = jest.spyOn(useHasDataHook, 'useHasData').mockReturnValue({ + onRefreshTimeRange, + } as any); + return { spy, onRefreshTimeRange }; +}; + +export const mockAppDataView = (props?: Partial) => { + const loadDataView = jest.fn(); + const spy = jest.spyOn(useAppDataViewHook, 'useAppDataViewContext').mockReturnValue({ + dataView: mockDataView, + hasData: true, + loading: false, + hasAppData: { ux: true } as any, + loadDataView, + dataViews: { ux: mockDataView } as unknown as Record, + dataViewErrors: {} as any, + ...(props || {}), + }); + return { spy, loadDataView }; +}; + +export const mockUseValuesList = (values?: ListItem[]) => { + const onRefreshTimeRange = jest.fn(); + const spy = jest.spyOn(useValuesListHook, 'useValuesList').mockReturnValue({ + values: values ?? [], + } as any); + return { spy, onRefreshTimeRange }; +}; + +export const mockUxSeries = { + name: 'performance-distribution', + dataType: 'ux', + breakdown: 'user_agent.name', + time: { from: 'now-15m', to: 'now' }, + reportDefinitions: { 'service.name': ['elastic-co'] }, + selectedMetricField: TRANSACTION_DURATION, +} as SeriesUrl; + +function mockSeriesStorageContext({ + data, + filters, + breakdown, +}: { + data?: AllSeries; + filters?: UrlFilter[]; + breakdown?: string; +}) { + const testSeries = { + ...mockUxSeries, + breakdown: breakdown || 'user_agent.name', + ...(filters ? { filters } : {}), + }; + + const mockDataSeries = data || [testSeries]; + + const removeSeries = jest.fn(); + const setSeries = jest.fn(); + + const getSeries = jest.fn().mockReturnValue(testSeries); + + return { + removeSeries, + setSeries, + getSeries, + autoApply: true, + reportType: 'data-distribution', + lastRefresh: Date.now(), + setLastRefresh: jest.fn(), + setAutoApply: jest.fn(), + applyChanges: jest.fn(), + firstSeries: mockDataSeries[0], + allSeries: mockDataSeries, + setReportType: jest.fn(), + setChartTimeRangeContext: jest.fn(), + storage: { + get: jest + .fn() + .mockImplementation((key: string) => + key === reportTypeKey ? 'data-distribution' : mockDataSeries + ), + } as any, + } as SeriesContextValue; +} + +export function mockUseSeriesFilter() { + const removeFilter = jest.fn(); + const invertFilter = jest.fn(); + const setFilter = jest.fn(); + const replaceFilter = jest.fn(); + const setFiltersWildcard = jest.fn(); + const spy = jest.spyOn(useSeriesFilterHook, 'useSeriesFilters').mockReturnValue({ + removeFilter, + invertFilter, + setFilter, + replaceFilter, + setFiltersWildcard, + }); + + return { + spy, + removeFilter, + invertFilter, + setFilter, + replaceFilter, + setFiltersWildcard, + }; +} + +const hist = createMemoryHistory(); +export const mockHistory = { + ...hist, + createHref: jest.fn(({ pathname }) => `/observability${pathname}`), + push: jest.fn(), + location: { + ...hist.location, + pathname: '/current-path', + }, +}; + +const fieldFormatMap: DataViewSpec['fieldFormats'] = {}; + +rumFieldFormats.forEach(({ field, format }) => { + fieldFormatMap[field] = format; +}); + +export const mockDataView = createStubDataView({ + spec: { + id: 'apm-*', + title: 'apm-*', + timeFieldName: '@timestamp', + fields: JSON.parse(dataViewData.attributes.fields), + fieldFormats: fieldFormatMap, + }, +}); + +// This function allows us to query for the nearest button with test +// no matter whether it has nested tags or not (as EuiButton elements do). +export const forNearestButton = + (getByText: (f: MatcherFunction) => HTMLElement | null) => + (text: string): HTMLElement | null => + getByText((_content: string, node: Element | null) => { + if (!node) return false; + const noOtherButtonHasText = Array.from(node.children).every( + (child) => child && (child.textContent !== text || child.tagName.toLowerCase() !== 'button') + ); + return ( + noOtherButtonHasText && node.textContent === text && node.tagName.toLowerCase() === 'button' + ); + }); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.test.tsx new file mode 100644 index 0000000000000..37a554ba334d1 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; +import { Breakdowns } from './breakdowns'; +import { mockDataView, mockUxSeries, render } from '../../rtl_helpers'; +import { getDefaultConfigs } from '../../configurations/default_configs'; +import { RECORDS_FIELD } from '../../configurations/constants'; +import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames'; +import { obsvReportConfigMap } from '../../obsv_exploratory_view'; + +describe('Breakdowns', function () { + const dataViewSeries = getDefaultConfigs({ + reportType: 'data-distribution', + dataView: mockDataView, + dataType: 'ux', + reportConfigMap: obsvReportConfigMap, + }); + + it('should render properly', async function () { + render(); + + screen.getAllByText('Browser family'); + }); + + it('should call set series on change', function () { + const initSeries = { breakdown: USER_AGENT_OS }; + + const { setSeries } = render( + , + { initSeries } + ); + + screen.getAllByText('Operating system'); + + fireEvent.click(screen.getByTestId('seriesBreakdown')); + + fireEvent.click(screen.getByText('Browser family')); + + expect(setSeries).toHaveBeenCalledWith(0, { + breakdown: 'user_agent.name', + dataType: 'ux', + name: 'performance-distribution', + reportDefinitions: { + 'service.name': ['elastic-co'], + }, + selectedMetricField: 'transaction.duration.us', + time: { from: 'now-15m', to: 'now' }, + }); + expect(setSeries).toHaveBeenCalledTimes(1); + }); + + it('does not show percentile breakdown for records metrics', function () { + const kpiConfig = getDefaultConfigs({ + reportType: 'kpi-over-time', + dataView: mockDataView, + dataType: 'ux', + reportConfigMap: obsvReportConfigMap, + }); + + render( + + ); + + fireEvent.click(screen.getByTestId('seriesBreakdown')); + + expect(screen.queryByText('Percentile')).not.toBeInTheDocument(); + }); + + it('should disable breakdowns when a different series has a breakdown', function () { + const initSeries = { + data: [mockUxSeries, { ...mockUxSeries, breakdown: undefined }], + breakdown: USER_AGENT_OS, + }; + + render( + , + { initSeries } + ); + + const button = screen.getByText('No breakdown'); + + expect(button).toHaveAttribute('disabled'); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.tsx new file mode 100644 index 0000000000000..0d9d4827a83ab --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.tsx @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; +import styled from 'styled-components'; +import { EuiSuperSelect, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { + LABEL_FIELDS_BREAKDOWN, + USE_BREAK_DOWN_COLUMN, + RECORDS_FIELD, + PERCENTILE, +} from '../../configurations/constants'; +import { SeriesConfig, SeriesUrl } from '../../types'; +import { SYNTHETICS_STEP_NAME } from '../../configurations/constants/field_names/synthetics'; +import { isStepLevelMetric } from '../../configurations/synthetics/kpi_over_time_config'; + +interface Props { + seriesId: number; + series: SeriesUrl; + seriesConfig?: SeriesConfig; +} + +export function Breakdowns({ seriesConfig, seriesId, series }: Props) { + const { setSeries, allSeries } = useSeriesStorage(); + + const indexOfSeriesWithBreakdown = allSeries.findIndex((seriesT) => { + return Boolean(seriesT.breakdown); + }); + const currentSeriesHasBreakdown = indexOfSeriesWithBreakdown === seriesId; + const anySeriesHasBreakdown = indexOfSeriesWithBreakdown !== -1; + const differentSeriesHasBreakdown = anySeriesHasBreakdown && !currentSeriesHasBreakdown; + + const selectedBreakdown = series.breakdown; + const NO_BREAKDOWN = 'no_breakdown'; + + const onOptionChange = (optionId: string) => { + if (optionId === NO_BREAKDOWN) { + setSeries(seriesId, { + ...series, + breakdown: undefined, + }); + } else { + setSeries(seriesId, { + ...series, + breakdown: selectedBreakdown === optionId ? undefined : optionId, + }); + } + }; + + useEffect(() => { + if ( + !isStepLevelMetric(series.selectedMetricField) && + selectedBreakdown === SYNTHETICS_STEP_NAME + ) { + setSeries(seriesId, { + ...series, + breakdown: undefined, + }); + } + }); + + if (!seriesConfig) { + return null; + } + + const hasUseBreakdownColumn = seriesConfig.xAxisColumn.sourceField === USE_BREAK_DOWN_COLUMN; + const isRecordsMetric = series.selectedMetricField === RECORDS_FIELD; + + const items = seriesConfig.breakdownFields.map((breakdown) => ({ + id: breakdown, + label: seriesConfig.labels[breakdown] ?? breakdown, + })); + + if (!hasUseBreakdownColumn) { + items.push({ + id: NO_BREAKDOWN, + label: NO_BREAK_DOWN_LABEL, + }); + } + + const options = items + .map(({ id, label }) => { + if (id === SYNTHETICS_STEP_NAME && !isStepLevelMetric(series.selectedMetricField)) { + return { + inputDisplay: label, + value: id, + dropdownDisplay: ( + + <>{label} + + ), + disabled: true, + }; + } else { + return { + inputDisplay: label, + value: id, + dropdownDisplay: label, + }; + } + }) + .filter(({ value }) => !(value === PERCENTILE && isRecordsMetric)); + + let valueOfSelected = + selectedBreakdown || (hasUseBreakdownColumn ? options[0].value : NO_BREAKDOWN); + + if (selectedBreakdown?.startsWith('labels.')) { + valueOfSelected = LABEL_FIELDS_BREAKDOWN; + } + + function Select() { + return ( + onOptionChange(value)} + data-test-subj={'seriesBreakdown'} + disabled={differentSeriesHasBreakdown} + /> + ); + } + + return ( + + {differentSeriesHasBreakdown ? ( + + + )} + + ); +} + +export const NO_BREAK_DOWN_LABEL = i18n.translate( + 'xpack.exploratoryView.exp.breakDownFilter.noBreakdown', + { + defaultMessage: 'No breakdown', + } +); + +export const BREAKDOWN_WARNING = i18n.translate( + 'xpack.exploratoryView.exp.breakDownFilter.warning', + { + defaultMessage: 'Breakdowns can be applied to only one series at a time.', + } +); + +export const BREAKDOWN_UNAVAILABLE = i18n.translate( + 'xpack.exploratoryView.exp.breakDownFilter.unavailable', + { + defaultMessage: + 'Step name breakdown is not available for monitor duration metric. Use step duration metric to breakdown by step name.', + } +); + +const Wrapper = styled.span` + .euiToolTipAnchor { + width: 100%; + } +`; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/breakdown/label_breakdown.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/breakdown/label_breakdown.tsx new file mode 100644 index 0000000000000..da79a70300500 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/breakdown/label_breakdown.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiComboBox, EuiFlexItem } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { SeriesConfig, SeriesUrl } from '../../types'; +import { useAppDataViewContext } from '../../hooks/use_app_data_view'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { LABEL_FIELDS_BREAKDOWN } from '../../configurations/constants'; + +interface Props { + seriesId: number; + series: SeriesUrl; + seriesConfig?: SeriesConfig; +} +export function LabelsBreakdown({ series, seriesId }: Props) { + const { dataView } = useAppDataViewContext(series.dataType); + + const labelFields = dataView?.fields.filter((field) => field.name.startsWith('labels.')); + + const { setSeries } = useSeriesStorage(); + + const { breakdown } = series; + + const hasLabelBreakdown = + breakdown === LABEL_FIELDS_BREAKDOWN || breakdown?.startsWith('labels.'); + + if (!hasLabelBreakdown) { + return null; + } + + const labelFieldOptions = labelFields?.map((field) => { + return { + label: field.name, + value: field.name, + }; + }); + + return ( + + labelField.label === breakdown)} + options={labelFieldOptions} + placeholder={CHOOSE_BREAKDOWN_FIELD} + onChange={(value) => { + setSeries(seriesId, { + ...series, + breakdown: value?.[0]?.label ?? LABEL_FIELDS_BREAKDOWN, + }); + }} + singleSelection={{ asPlainText: true }} + isInvalid={series.breakdown === LABEL_FIELDS_BREAKDOWN} + /> + + ); +} + +export const CHOOSE_BREAKDOWN_FIELD = i18n.translate( + 'xpack.exploratoryView.expView.seriesBuilder.labelField', + { + defaultMessage: 'Choose label field', + } +); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/chart_type_select.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/chart_type_select.tsx new file mode 100644 index 0000000000000..1fd75d47846ab --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/chart_type_select.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { EuiPopover, EuiToolTip, EuiButtonEmpty, EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useFetcher } from '@kbn/observability-plugin/public'; +import { ExploratoryViewPublicPluginsStart } from '../../../../../plugin'; +import { SeriesUrl } from '../../../../..'; +import { SeriesConfig } from '../../types'; +import { SeriesChartTypesSelect } from './chart_types'; + +interface Props { + seriesId: number; + series: SeriesUrl; + seriesConfig: SeriesConfig; +} + +export function SeriesChartTypes({ seriesId, series, seriesConfig }: Props) { + const seriesType = series?.seriesType ?? seriesConfig.defaultSeriesType; + + const { + services: { lens }, + } = useKibana(); + + const { data = [] } = useFetcher(() => lens.getXyVisTypes(), [lens]); + + const icon = (data ?? []).find(({ id }) => id === seriesType)?.icon; + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( + setIsPopoverOpen(false)} + button={ + + setIsPopoverOpen((prevState) => !prevState)} + flush="both" + > + {icon && ( + id === seriesType)?.icon!} size="l" /> + )} + + + } + > + + + ); +} + +const EDIT_CHART_TYPE_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.seriesEditor.editChartSeriesLabel', + { + defaultMessage: 'Edit chart type for series', + } +); + +const CHART_TYPE_LABEL = i18n.translate('xpack.exploratoryView.expView.chartTypes.label', { + defaultMessage: 'Chart type', +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/chart_types.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/chart_types.test.tsx new file mode 100644 index 0000000000000..8f196b8a05dda --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/chart_types.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { mockUxSeries, render } from '../../rtl_helpers'; +import { SeriesChartTypesSelect, XYChartTypesSelect } from './chart_types'; + +describe.skip('SeriesChartTypesSelect', function () { + it('should render properly', async function () { + render(); + + await waitFor(() => { + screen.getByText(/chart type/i); + }); + }); + + it('should call set series on change', async function () { + const { setSeries } = render( + + ); + + await waitFor(() => { + screen.getByText(/chart type/i); + }); + + fireEvent.click(screen.getByText(/chart type/i)); + fireEvent.click(screen.getByTestId('lnsXY_seriesType-bar_stacked')); + + expect(setSeries).toHaveBeenNthCalledWith(1, 'performance-distribution', { + breakdown: 'user_agent.name', + reportType: 'pld', + seriesType: 'bar_stacked', + time: { from: 'now-15m', to: 'now' }, + }); + expect(setSeries).toHaveBeenCalledTimes(3); + }); + + describe('XYChartTypesSelect', function () { + it('should render properly', async function () { + render(); + + await waitFor(() => { + screen.getByText(/chart type/i); + }); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/chart_types.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/chart_types.tsx new file mode 100644 index 0000000000000..68ccd782e4536 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/chart_types.tsx @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIcon, EuiSuperSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { SeriesType } from '@kbn/lens-plugin/public'; +import { useFetcher } from '@kbn/observability-plugin/public'; +import { ExploratoryViewPublicPluginsStart } from '../../../../../plugin'; +import { SeriesUrl } from '../../../../..'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; + +const CHART_TYPE_LABEL = i18n.translate('xpack.exploratoryView.expView.chartTypes.label', { + defaultMessage: 'Chart type', +}); + +export function SeriesChartTypesSelect({ + seriesId, + series, + defaultChartType, +}: { + seriesId: number; + series: SeriesUrl; + defaultChartType: SeriesType; +}) { + const { setSeries } = useSeriesStorage(); + + const seriesType = series?.seriesType ?? defaultChartType; + + const onChange = (value: SeriesType) => { + setSeries(seriesId, { ...series, seriesType: value }); + }; + + return ( + + ); +} + +export interface XYChartTypesProps { + label?: string; + value: SeriesType; + includeChartTypes?: SeriesType[]; + excludeChartTypes?: SeriesType[]; + onChange: (value: SeriesType) => void; +} + +export function XYChartTypesSelect({ + onChange, + value, + includeChartTypes, + excludeChartTypes, +}: XYChartTypesProps) { + const { + services: { lens }, + } = useKibana(); + + const { data = [], loading } = useFetcher(() => lens.getXyVisTypes(), [lens]); + + let vizTypes = data; + + if ((excludeChartTypes ?? []).length > 0) { + vizTypes = vizTypes.filter(({ id }) => !excludeChartTypes?.includes(id as SeriesType)); + } + + if ((includeChartTypes ?? []).length > 0) { + vizTypes = vizTypes.filter(({ id }) => includeChartTypes?.includes(id as SeriesType)); + } + + const options = (vizTypes ?? []).map(({ id, fullLabel, label, icon }) => { + const LabelWithIcon = ( + + + + + {fullLabel || label} + + ); + return { + value: id as SeriesType, + inputDisplay: LabelWithIcon, + dropdownDisplay: LabelWithIcon, + }; + }); + + return ( + + + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/data_type_select.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/data_type_select.test.tsx new file mode 100644 index 0000000000000..c3457c2938630 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/data_type_select.test.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; +import { mockAppDataView, mockUxSeries, render } from '../../rtl_helpers'; +import { DataTypesSelect } from './data_type_select'; +import { DataTypes, DataTypesLabels } from '../../labels'; + +describe('DataTypeSelect', function () { + const seriesId = 0; + + mockAppDataView(); + + it('should render properly', function () { + render(); + }); + + it('should set series on change', async function () { + const seriesWithoutDataType = { + ...mockUxSeries, + dataType: undefined, + }; + const { setSeries } = render( + + ); + + fireEvent.click(await screen.findByText('Select data type')); + fireEvent.click(await screen.findByText(DataTypesLabels[DataTypes.SYNTHETICS])); + + expect(setSeries).toHaveBeenCalledTimes(1); + expect(setSeries).toHaveBeenCalledWith(seriesId, { + dataType: 'synthetics', + name: 'synthetics-series-1', + time: { + from: 'now-15m', + to: 'now', + }, + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/data_type_select.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/data_type_select.tsx new file mode 100644 index 0000000000000..3ff9ec0fb4bb6 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/data_type_select.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useCallback } from 'react'; +import { + EuiButton, + EuiPopover, + EuiListGroup, + EuiListGroupItem, + EuiBadge, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { AppDataType, SeriesUrl } from '../../types'; +import { useExploratoryView } from '../../contexts/exploratory_view_config'; + +interface Props { + seriesId: number; + series: Omit & { + dataType?: SeriesUrl['dataType']; + }; +} + +const SELECT_DATA_TYPE = 'SELECT_DATA_TYPE'; + +export function DataTypesSelect({ seriesId, series }: Props) { + const { setSeries, reportType } = useSeriesStorage(); + const [showOptions, setShowOptions] = useState(false); + + const focusButton = useCallback((ref: HTMLButtonElement) => { + ref?.focus(); + }, []); + + const onDataTypeChange = (dataType: AppDataType) => { + if (String(dataType) !== SELECT_DATA_TYPE) { + setSeries(seriesId, { + dataType, + time: series.time, + name: `${dataType}-series-${seriesId + 1}`, + }); + } + }; + + const { dataTypes, reportConfigMap } = useExploratoryView(); + + const options = dataTypes + .filter(({ id }) => { + return reportConfigMap[id]?.find((config) => config({}).reportType === reportType); + }) + .map(({ id, label }) => ({ + value: id, + inputDisplay: label, + })); + + const currDataType = dataTypes.find((dt) => dt.id === series.dataType); + + return ( + <> + {!series.dataType && ( + setShowOptions((prevState) => !prevState)} + fill + size="s" + buttonRef={focusButton} + > + {SELECT_DATA_TYPE_LABEL} + + } + isOpen={showOptions} + closePopover={() => setShowOptions((prevState) => !prevState)} + > + + {options.map((option) => ( + onDataTypeChange(option.value)} + label={option.inputDisplay} + /> + ))} + + + )} + {series.dataType && ( + + {currDataType?.label} + + )} + + ); +} + +const SELECT_DATA_TYPE_LABEL = i18n.translate( + 'xpack.exploratoryView.overview.exploratoryView.selectDataType', + { + defaultMessage: 'Select data type', + } +); + +const SELECT_DATA_TYPE_TOOLTIP = i18n.translate( + 'xpack.exploratoryView.overview.exploratoryView.selectDataTypeTooltip', + { + defaultMessage: 'Data type cannot be edited.', + } +); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/date_picker_col.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/date_picker_col.tsx new file mode 100644 index 0000000000000..d294d2a48b963 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/date_picker_col.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { DateRangePicker } from '../../components/date_range_picker'; +import { SeriesDatePicker } from '../../components/series_date_picker'; +import { AppDataType, SeriesUrl } from '../../types'; +import { ReportTypes } from '../../configurations/constants'; +import { useAppDataViewContext } from '../../hooks/use_app_data_view'; +import { SyntheticsAddData } from '../../../add_data_buttons/synthetics_add_data'; +import { MobileAddData } from '../../../add_data_buttons/mobile_add_data'; +import { UXAddData } from '../../../add_data_buttons/ux_add_data'; + +interface Props { + seriesId: number; + series: SeriesUrl; +} + +const AddDataComponents: Record = { + mobile: MobileAddData, + ux: UXAddData, + uptime: SyntheticsAddData, + synthetics: null, + apm: null, + infra_logs: null, + infra_metrics: null, + alerts: null, +}; + +export function DatePickerCol({ seriesId, series }: Props) { + const { reportType } = useSeriesStorage(); + + const { hasAppData } = useAppDataViewContext(); + + if (!series.dataType) { + return null; + } + + const AddDataButton = AddDataComponents[series.dataType]; + if (hasAppData[series.dataType] === false && AddDataButton !== null) { + return ( + + + + {i18n.translate('xpack.exploratoryView.overview.exploratoryView.noDataAvailable', { + defaultMessage: 'No {dataType} data available.', + values: { + dataType: series.dataType, + }, + })} + + + + + + + ); + } + + return ( + + {seriesId === 0 || reportType !== ReportTypes.KPI ? ( + + ) : ( + + )} + + ); +} + +const Wrapper = styled.div` + width: 100%; + .euiSuperDatePicker__flexWrapper { + width: 100%; + > .euiFlexItem { + margin-right: 0; + } + } +`; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx new file mode 100644 index 0000000000000..d821e65afae04 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { FilterExpanded } from './filter_expanded'; +import { mockUxSeries, mockAppDataView, mockUseValuesList, render } from '../../rtl_helpers'; +import { USER_AGENT_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; + +describe('FilterExpanded', function () { + const filters = [{ field: USER_AGENT_NAME, values: ['Chrome'] }]; + + const mockSeries = { ...mockUxSeries, filters }; + + it('render', async () => { + const initSeries = { filters }; + mockAppDataView(); + + render( + , + { initSeries } + ); + + await waitFor(() => { + screen.getByText('Browser Family'); + }); + }); + + it('should call go back on click', async function () { + const initSeries = { filters }; + + render( + , + { initSeries } + ); + + await waitFor(() => { + fireEvent.click(screen.getByText('Browser Family')); + }); + }); + + it('calls useValuesList on load', async () => { + const initSeries = { filters }; + + const { spy } = mockUseValuesList([ + { label: 'Chrome', count: 10 }, + { label: 'Firefox', count: 5 }, + ]); + + render( + , + { initSeries } + ); + + await waitFor(() => { + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toBeCalledWith( + expect.objectContaining({ + time: { from: 'now-15m', to: 'now' }, + sourceField: USER_AGENT_NAME, + }) + ); + }); + }); + + it('filters display values', async () => { + const initSeries = { filters }; + + mockUseValuesList([ + { label: 'Chrome', count: 10 }, + { label: 'Firefox', count: 5 }, + ]); + + render( + , + { initSeries } + ); + + await waitFor(() => { + fireEvent.click(screen.getByText('Browser Family')); + + expect(screen.queryByText('Firefox')).toBeTruthy(); + + fireEvent.input(screen.getByRole('searchbox'), { target: { value: 'ch' } }); + + expect(screen.queryByText('Firefox')).toBeFalsy(); + expect(screen.getByText('Chrome')).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx new file mode 100644 index 0000000000000..09b9f443389ce --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; + +import { EuiFilterButton, EuiPopover } from '@elastic/eui'; +import { SeriesConfig, SeriesUrl } from '../../types'; +import { useFilterValues } from '../use_filter_values'; +import { FilterValuesList } from '../components/filter_values_list'; + +export interface FilterProps { + seriesId: number; + series: SeriesUrl; + label: string; + field: string; + isNegated?: boolean; + nestedField?: string; + baseFilters: SeriesConfig['baseFilters']; +} + +export interface NestedFilterOpen { + value: string; + negate: boolean; +} + +export function FilterExpanded(props: FilterProps) { + const [isOpen, setIsOpen] = useState(false); + + const [query, setQuery] = useState(''); + + const { values, loading } = useFilterValues(props, query); + + return ( + setIsOpen((prevState) => !prevState)} iconType="arrowDown"> + {props.label} + + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + > + + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx new file mode 100644 index 0000000000000..66f52875cfc74 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx @@ -0,0 +1,267 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { FilterValueButton } from './filter_value_btn'; +import { mockUxSeries, mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers'; +import { + USER_AGENT_NAME, + USER_AGENT_VERSION, +} from '../../configurations/constants/elasticsearch_fieldnames'; + +describe('FilterValueButton', function () { + it('renders', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Chrome')).toBeInTheDocument(); + }); + }); + + describe('when negate is true', () => { + it('displays negate stats', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Not Chrome')).toBeInTheDocument(); + expect(screen.getByTitle('Not Chrome')).toBeInTheDocument(); + const btn = screen.getByRole('button'); + expect(btn.classList[4]).toContain('empty-danger'); + }); + }); + + it('calls setFilter on click', async () => { + const { setFilter, removeFilter } = mockUseSeriesFilter(); + + render( + + ); + + fireEvent.click(screen.getByText('Not Chrome')); + + await waitFor(() => { + expect(removeFilter).toHaveBeenCalledTimes(0); + expect(setFilter).toHaveBeenCalledTimes(1); + + expect(setFilter).toHaveBeenCalledWith({ + field: 'user_agent.name', + negate: true, + value: 'Chrome', + }); + }); + }); + }); + + describe('when selected', () => { + it('removes the filter on click', async () => { + const { removeFilter } = mockUseSeriesFilter(); + + render( + + ); + + fireEvent.click(screen.getByText('Chrome')); + + await waitFor(() => { + expect(removeFilter).toHaveBeenCalledWith({ + field: 'user_agent.name', + negate: false, + value: 'Chrome', + }); + }); + }); + }); + + it('should change filter on negated one', async function () { + const { removeFilter } = mockUseSeriesFilter(); + + render( + + ); + + fireEvent.click(screen.getByText('Not Chrome')); + + await waitFor(() => { + expect(removeFilter).toHaveBeenCalledWith({ + field: 'user_agent.name', + negate: true, + value: 'Chrome', + }); + }); + }); + + it('should force open nested', async function () { + mockUseSeriesFilter(); + const { spy } = mockUseValuesList(); + + render( + + ); + + await waitFor(() => { + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toBeCalledWith( + expect.objectContaining({ + filters: [ + { + term: { + [USER_AGENT_NAME]: 'Chrome', + }, + }, + ], + sourceField: 'user_agent.version', + }) + ); + }); + }); + it('should set isNestedOpen on click', async function () { + mockUseSeriesFilter(); + const { spy } = mockUseValuesList(); + + render( + + ); + + await waitFor(() => { + expect(spy).toHaveBeenCalledTimes(6); + expect(spy).toBeCalledWith( + expect.objectContaining({ + filters: [ + { + term: { + [USER_AGENT_NAME]: 'Chrome', + }, + }, + ], + sourceField: USER_AGENT_VERSION, + }) + ); + }); + }); + + it('should set call setIsNestedOpen on click selected', async function () { + mockUseSeriesFilter(); + mockUseValuesList(); + + const setIsNestedOpen = jest.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('Chrome')); + await waitFor(() => { + expect(setIsNestedOpen).toHaveBeenCalledTimes(1); + expect(setIsNestedOpen).toHaveBeenCalledWith({ negate: false, value: '' }); + }); + }); + + it('should set call setIsNestedOpen on click not selected', async function () { + mockUseSeriesFilter(); + mockUseValuesList(); + + const setIsNestedOpen = jest.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('Not Chrome')); + await waitFor(() => { + expect(setIsNestedOpen).toHaveBeenCalledTimes(1); + expect(setIsNestedOpen).toHaveBeenCalledWith({ negate: true, value: 'Chrome' }); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx new file mode 100644 index 0000000000000..a1dd68f65c3e7 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; + +import React, { useMemo } from 'react'; +import { EuiFilterButton, hexToRgb } from '@elastic/eui'; +import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { useAppDataViewContext } from '../../hooks/use_app_data_view'; +import { useSeriesFilters } from '../../hooks/use_series_filters'; +import FieldValueSuggestions from '../../../field_value_suggestions'; +import { SeriesUrl } from '../../types'; +import { NestedFilterOpen } from './filter_expanded'; + +interface Props { + value: string; + field: string; + allSelectedValues?: Array; + negate: boolean; + nestedField?: string; + seriesId: number; + series: SeriesUrl; + isNestedOpen: { + value: string; + negate: boolean; + }; + setIsNestedOpen: (val: NestedFilterOpen) => void; +} + +export function FilterValueButton({ + isNestedOpen, + setIsNestedOpen, + value, + field, + negate, + seriesId, + series, + nestedField, + allSelectedValues, +}: Props) { + const { dataViews } = useAppDataViewContext(series.dataType); + + const { setFilter, removeFilter } = useSeriesFilters({ seriesId, series }); + + const hasActiveFilters = (allSelectedValues ?? []).includes(value); + + const button = ( + { + if (hasActiveFilters) { + removeFilter({ field, value, negate }); + } else { + setFilter({ field, value, negate }); + } + if (!hasActiveFilters) { + setIsNestedOpen({ value, negate }); + } else { + setIsNestedOpen({ value: '', negate }); + } + }} + > + {negate + ? i18n.translate('xpack.exploratoryView.expView.filterValueButton.negate', { + defaultMessage: 'Not {value}', + values: { value }, + }) + : value} + + ); + + const onNestedChange = (valuesN?: string[]) => { + (valuesN ?? []).forEach((valN) => { + setFilter({ field: nestedField!, value: valN! }); + }); + setIsNestedOpen({ value: '', negate }); + }; + + const forceOpenNested = isNestedOpen?.value === value && isNestedOpen.negate === negate; + + const filters = useMemo(() => { + return [ + { + term: { + [field]: value, + }, + }, + ]; + }, [field, value]); + + return nestedField && forceOpenNested ? ( + + ) : ( + button + ); +} + +const FilterButton = euiStyled(EuiFilterButton)` + background-color: rgba(${(props) => { + const color = props.hasActiveFilters + ? props.color === 'danger' + ? hexToRgb(props.theme.eui.euiColorDanger) + : hexToRgb(props.theme.eui.euiColorPrimary) + : 'initial'; + return `${color[0]}, ${color[1]}, ${color[2]}, 0.1`; + }}); +`; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/incomplete_badge.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/incomplete_badge.tsx new file mode 100644 index 0000000000000..456b74c0c19e8 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/incomplete_badge.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { isEmpty } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { EuiBadge } from '@elastic/eui'; +import { useAppDataViewContext } from '../../hooks/use_app_data_view'; +import { SeriesConfig, SeriesUrl } from '../../types'; + +interface Props { + series: SeriesUrl; + seriesConfig?: SeriesConfig; +} + +export function IncompleteBadge({ seriesConfig, series }: Props) { + const { loading } = useAppDataViewContext(); + + if (!seriesConfig) { + return null; + } + const { dataType, reportDefinitions, selectedMetricField } = series; + const { definitionFields, labels } = seriesConfig; + const isIncomplete = + (!dataType || isEmpty(reportDefinitions) || !selectedMetricField) && !loading; + + const incompleteDefinition = isEmpty(reportDefinitions) + ? i18n.translate('xpack.exploratoryView.overview.exploratoryView.missingReportDefinition', { + defaultMessage: 'Missing {reportDefinition}', + values: { + reportDefinition: + labels?.[ + typeof definitionFields[0] === 'string' + ? definitionFields[0] + : definitionFields[0]?.field + ], + }, + }) + : ''; + + let incompleteMessage = !selectedMetricField ? MISSING_REPORT_METRIC_LABEL : incompleteDefinition; + + if (!dataType) { + incompleteMessage = MISSING_DATA_TYPE_LABEL; + } + + if (!isIncomplete) { + return null; + } + + return {incompleteMessage}; +} + +const MISSING_REPORT_METRIC_LABEL = i18n.translate( + 'xpack.exploratoryView.overview.exploratoryView.missingReportMetric', + { + defaultMessage: 'Missing report metric', + } +); + +const MISSING_DATA_TYPE_LABEL = i18n.translate( + 'xpack.exploratoryView.overview.exploratoryView.missingDataType', + { + defaultMessage: 'Missing data type', + } +); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.test.tsx new file mode 100644 index 0000000000000..94761f3611869 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; +import { mockUxSeries, render } from '../../rtl_helpers'; +import { OperationTypeSelect } from './operation_type_select'; + +describe('OperationTypeSelect', function () { + it('should render properly', function () { + render(); + + // SR-only text 'Select an option: , is selected' + // was removed here: https://github.com/elastic/eui/pull/6630#discussion_r1123655995 + screen.getByRole('button'); + }); + + it('should display selected value', function () { + const initSeries = { + data: [ + { + name: 'performance-distribution', + dataType: 'ux' as const, + operationType: 'median' as const, + time: { from: 'now-15m', to: 'now' }, + }, + ], + }; + + render(, { + initSeries, + }); + + screen.getByText('Median'); + }); + + it('should call set series on change', function () { + const initSeries = { + data: [ + { + name: 'performance-distribution', + dataType: 'ux' as const, + operationType: 'median' as const, + time: { from: 'now-15m', to: 'now' }, + }, + ], + }; + + const { setSeries } = render(, { + initSeries, + }); + + fireEvent.click(screen.getByTestId('operationTypeSelect')); + + fireEvent.click(screen.getByText('95th Percentile')); + expect(setSeries).toHaveBeenCalledWith(0, { + operationType: '95th', + dataType: 'ux', + time: { from: 'now-15m', to: 'now' }, + name: 'performance-distribution', + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.tsx new file mode 100644 index 0000000000000..ed76e6b25a229 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSuperSelect } from '@elastic/eui'; + +import { OperationType } from '@kbn/lens-plugin/public'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { SeriesUrl } from '../../types'; + +export function OperationTypeSelect({ + seriesId, + series, + defaultOperationType, +}: { + seriesId: number; + series: SeriesUrl; + defaultOperationType?: OperationType; +}) { + const { setSeries } = useSeriesStorage(); + + const operationType = series?.operationType; + + const onChange = (value: OperationType) => { + setSeries(seriesId, { ...series, operationType: value }); + }; + + return ( + + ); +} + +export function OperationTypeComponent({ + operationType, + onChange, +}: { + operationType?: OperationType; + onChange: (value: OperationType) => void; +}) { + const options = [ + { + value: 'min' as OperationType, + inputDisplay: i18n.translate('xpack.exploratoryView.expView.operationType.min', { + defaultMessage: 'Min', + }), + }, + { + value: 'max' as OperationType, + inputDisplay: i18n.translate('xpack.exploratoryView.expView.operationType.max', { + defaultMessage: 'Max', + }), + }, + { + value: 'average' as OperationType, + inputDisplay: i18n.translate('xpack.exploratoryView.expView.operationType.average', { + defaultMessage: 'Average', + }), + }, + { + value: 'median' as OperationType, + inputDisplay: i18n.translate('xpack.exploratoryView.expView.operationType.median', { + defaultMessage: 'Median', + }), + }, + { + value: 'sum' as OperationType, + inputDisplay: i18n.translate('xpack.exploratoryView.expView.operationType.sum', { + defaultMessage: 'Sum', + }), + }, + { + value: 'last_value' as OperationType, + inputDisplay: i18n.translate('xpack.exploratoryView.expView.operationType.lastValue', { + defaultMessage: 'Last value', + }), + }, + { + value: 'unique_count' as OperationType, + inputDisplay: i18n.translate('xpack.exploratoryView.expView.operationType.uniqueCount', { + defaultMessage: 'Unique count', + }), + }, + { + value: '25th' as OperationType, + inputDisplay: i18n.translate('xpack.exploratoryView.expView.operationType.25thPercentile', { + defaultMessage: '25th Percentile', + }), + }, + { + value: '75th' as OperationType, + inputDisplay: i18n.translate('xpack.exploratoryView.expView.operationType.75thPercentile', { + defaultMessage: '75th Percentile', + }), + }, + { + value: '90th' as OperationType, + inputDisplay: i18n.translate('xpack.exploratoryView.expView.operationType.90thPercentile', { + defaultMessage: '90th Percentile', + }), + }, + { + value: '95th' as OperationType, + inputDisplay: i18n.translate('xpack.exploratoryView.expView.operationType.95thPercentile', { + defaultMessage: '95th Percentile', + }), + }, + { + value: '99th' as OperationType, + inputDisplay: i18n.translate('xpack.exploratoryView.expView.operationType.99thPercentile', { + defaultMessage: '99th Percentile', + }), + }, + ]; + + return ( + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.test.tsx new file mode 100644 index 0000000000000..980c02a0ab153 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { getDefaultConfigs } from '../../configurations/default_configs'; +import { + mockAppDataView, + mockDataView, + mockUseValuesList, + mockUxSeries, + render, +} from '../../rtl_helpers'; +import { ReportDefinitionCol } from './report_definition_col'; +import { obsvReportConfigMap } from '../../obsv_exploratory_view'; + +describe('Series Builder ReportDefinitionCol', function () { + mockAppDataView(); + const seriesId = 0; + + const seriesConfig = getDefaultConfigs({ + reportType: 'data-distribution', + dataView: mockDataView, + dataType: 'ux', + reportConfigMap: obsvReportConfigMap, + }); + + mockUseValuesList([{ label: 'elastic-co', count: 10 }]); + + it('renders', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Web Application')).toBeInTheDocument(); + expect(screen.getByText('Environment')).toBeInTheDocument(); + expect(screen.getByText('Search Environment')).toBeInTheDocument(); + }); + }); + + it('should render selected report definitions', async function () { + render( + + ); + + expect(await screen.findByText('elastic-co')).toBeInTheDocument(); + + expect(screen.getAllByTestId('comboBoxToggleListButton')[0]).toBeInTheDocument(); + }); + + it('should be able to remove selected definition', async function () { + const { setSeries } = render( + + ); + + expect( + await screen.findByLabelText('Remove elastic-co from selection in this group') + ).toBeInTheDocument(); + + fireEvent.click(screen.getAllByTestId('comboBoxToggleListButton')[0]); + + const removeBtn = await screen.findByTitle(/Remove elastic-co from selection in this group/i); + + fireEvent.click(removeBtn); + + expect(setSeries).toHaveBeenCalledTimes(1); + + expect(setSeries).toHaveBeenCalledWith(seriesId, { + dataType: 'ux', + name: 'performance-distribution', + breakdown: 'user_agent.name', + reportDefinitions: {}, + selectedMetricField: 'transaction.duration.us', + time: { from: 'now-15m', to: 'now' }, + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.tsx new file mode 100644 index 0000000000000..ccb439549c619 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { isEmpty } from 'lodash'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { SeriesConfig, SeriesUrl } from '../../types'; +import { ReportDefinitionField } from './report_definition_field'; +import { TextReportDefinitionField } from './text_report_definition_field'; +import { isStepLevelMetric } from '../../configurations/synthetics/kpi_over_time_config'; +import { SYNTHETICS_STEP_NAME } from '../../configurations/constants/field_names/synthetics'; + +export function ReportDefinitionCol({ + seriesId, + series, + seriesConfig, +}: { + seriesId: number; + series: SeriesUrl; + seriesConfig: SeriesConfig; +}) { + const { setSeries } = useSeriesStorage(); + + const { + reportDefinitions: selectedReportDefinitions = {}, + textReportDefinitions: selectedTextReportDefinitions = {}, + } = series; + + const { definitionFields, textDefinitionFields } = seriesConfig; + + const onChange = (field: string, value?: string[]) => { + if (!value?.[0]) { + delete selectedReportDefinitions[field]; + setSeries(seriesId, { + ...series, + reportDefinitions: { ...selectedReportDefinitions }, + }); + } else { + setSeries(seriesId, { + ...series, + reportDefinitions: { ...selectedReportDefinitions, [field]: value }, + }); + } + }; + + const onChangeTextDefinitionField = (field: string, value: string) => { + if (isEmpty(value)) { + delete selectedTextReportDefinitions[field]; + setSeries(seriesId, { + ...series, + textReportDefinitions: { ...selectedTextReportDefinitions }, + }); + } else { + setSeries(seriesId, { + ...series, + textReportDefinitions: { ...selectedTextReportDefinitions, [field]: value }, + }); + } + }; + + const hasFieldDataSelected = (field: string) => { + return !isEmpty(series.reportDefinitions?.[field]); + }; + + return ( + + {definitionFields.map((field) => { + const fieldStr = typeof field === 'string' ? field : field.field; + const singleSelection = typeof field !== 'string' && field.singleSelection; + const nestedField = typeof field !== 'string' && field.nested; + const filters = typeof field !== 'string' ? field.filters : undefined; + + const isNonStepMetric = !isStepLevelMetric(series.selectedMetricField); + + const hideNestedStep = nestedField === SYNTHETICS_STEP_NAME && isNonStepMetric; + + if (hideNestedStep && nestedField && selectedReportDefinitions[nestedField]?.length > 0) { + setSeries(seriesId, { + ...series, + reportDefinitions: { ...selectedReportDefinitions, [nestedField]: [] }, + }); + } + + let nestedFieldElement; + + if (nestedField && hasFieldDataSelected(fieldStr) && !hideNestedStep) { + nestedFieldElement = ( + + + + ); + } + + return ( + <> + + + + {nestedFieldElement} + + ); + })} + + {textDefinitionFields?.map((field) => { + return ( + + + + ); + })} + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/report_definition_field.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/report_definition_field.tsx new file mode 100644 index 0000000000000..7ccebcd84282d --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/report_definition_field.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { isEmpty } from 'lodash'; +import { ExistsFilter, PhraseFilter } from '@kbn/es-query'; +import type { ESFilter } from '@kbn/es-types'; +import { PersistableFilter } from '@kbn/lens-plugin/common'; +import { ALL_VALUES_SELECTED } from '../../configurations/constants/url_constants'; +import FieldValueSuggestions from '../../../field_value_suggestions'; +import { useAppDataViewContext } from '../../hooks/use_app_data_view'; +import { buildPhrasesFilter } from '../../configurations/utils'; +import { SeriesConfig, SeriesUrl } from '../../types'; + +interface Props { + seriesId: number; + series: SeriesUrl; + singleSelection?: boolean; + keepHistory?: boolean; + field: string | { field: string; nested: string }; + seriesConfig: SeriesConfig; + onChange: (field: string, value?: string[]) => void; + filters?: Array; +} + +export function ReportDefinitionField({ + singleSelection, + keepHistory, + series, + field: fieldProp, + seriesConfig, + onChange, + filters, +}: Props) { + const { dataView } = useAppDataViewContext(series.dataType); + + const field = typeof fieldProp === 'string' ? fieldProp : fieldProp.field; + + const { reportDefinitions: selectedReportDefinitions = {} } = series; + + const { labels, baseFilters, definitionFields } = seriesConfig; + + const queryFilters = useMemo(() => { + const filtersN: ESFilter[] = []; + (baseFilters ?? []) + .concat(filters ?? []) + .forEach((qFilter: PersistableFilter | ExistsFilter) => { + if (qFilter.query) { + filtersN.push(qFilter.query); + } + const existFilter = qFilter as ExistsFilter; + if (existFilter.query.exists) { + filtersN.push({ exists: existFilter.query.exists }); + } + }); + + if (!isEmpty(selectedReportDefinitions)) { + definitionFields.forEach((fieldObj) => { + const fieldT = typeof fieldObj === 'string' ? fieldObj : fieldObj.field; + + if (dataView && selectedReportDefinitions?.[fieldT] && fieldT !== field) { + const values = selectedReportDefinitions?.[fieldT]; + if (!values.includes(ALL_VALUES_SELECTED)) { + const valueFilter = buildPhrasesFilter(fieldT, values, dataView)[0]; + if (valueFilter.query) { + filtersN.push(valueFilter.query); + } + } + } + }); + } + + return filtersN; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(selectedReportDefinitions), JSON.stringify(baseFilters)]); + + if (!dataView) { + return null; + } + + return ( + onChange(field, val)} + filters={queryFilters} + time={series.time} + fullWidth={true} + asCombobox={true} + allowExclusions={false} + allowAllValuesSelection={true} + usePrependLabel={false} + compressed={false} + required={isEmpty(selectedReportDefinitions)} + singleSelection={singleSelection} + keepHistory={keepHistory} + /> + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/report_type_select.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/report_type_select.tsx new file mode 100644 index 0000000000000..d3dc4e11c3555 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/report_type_select.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiSuperSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { ReportViewType } from '../../types'; + +import { useExploratoryView } from '../../contexts/exploratory_view_config'; + +const SELECT_REPORT_TYPE = 'SELECT_REPORT_TYPE'; + +interface Props { + prepend: string; +} + +const SELECT_REPORT = { + reportType: SELECT_REPORT_TYPE, + label: i18n.translate('xpack.exploratoryView.expView.reportType.selectLabel', { + defaultMessage: 'Select report type', + }), +}; + +export function ReportTypesSelect({ prepend }: Props) { + const { setReportType, reportType: selectedReportType, allSeries } = useSeriesStorage(); + + const { reportTypes } = useExploratoryView(); + + const onReportTypeChange = (reportType: ReportViewType) => { + setReportType(reportType); + }; + + const options = [SELECT_REPORT, ...reportTypes] + .filter(({ reportType }) => (selectedReportType ? reportType !== SELECT_REPORT_TYPE : true)) + .map(({ reportType, label }) => ({ + value: reportType, + inputDisplay: reportType === SELECT_REPORT_TYPE ? label : {label}, + dropdownDisplay: label, + })); + + return ( + onReportTypeChange(value as ReportViewType)} + style={{ minWidth: 200 }} + isInvalid={!selectedReportType && allSeries.length > 0} + disabled={allSeries.length > 0} + prepend={prepend} + /> + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/selected_filters.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/selected_filters.test.tsx new file mode 100644 index 0000000000000..ef0f7c47d3f67 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/selected_filters.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import { mockAppDataView, mockDataView, mockUxSeries, render } from '../../rtl_helpers'; +import { SelectedFilters } from './selected_filters'; +import { getDefaultConfigs } from '../../configurations/default_configs'; +import { USER_AGENT_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; +import { obsvReportConfigMap } from '../../obsv_exploratory_view'; + +describe('SelectedFilters', function () { + mockAppDataView(); + + const dataViewSeries = getDefaultConfigs({ + reportType: 'data-distribution', + dataView: mockDataView, + dataType: 'ux', + reportConfigMap: obsvReportConfigMap, + }); + + it('should render properly', async function () { + const filters = [{ field: USER_AGENT_NAME, values: ['Chrome'] }]; + const initSeries = { filters }; + + render( + , + { + initSeries, + } + ); + + await waitFor(() => { + screen.getByText('Chrome'); + screen.getByTitle('Filter: Browser family: Chrome. Select for more filter actions.'); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/selected_filters.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/selected_filters.tsx new file mode 100644 index 0000000000000..6a849ff88d6bc --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/selected_filters.tsx @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, { Fragment } from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FilterLabel } from '../../components/filter_label'; +import { SeriesConfig, SeriesUrl, UrlFilter } from '../../types'; +import { useAppDataViewContext } from '../../hooks/use_app_data_view'; +import { useSeriesFilters } from '../../hooks/use_series_filters'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; + +interface Props { + seriesId: number; + series: SeriesUrl; + seriesConfig: SeriesConfig; +} +export function SelectedFilters({ seriesId, series, seriesConfig }: Props) { + const { setSeries } = useSeriesStorage(); + + const { labels } = seriesConfig; + + const filters: UrlFilter[] = series.filters ?? []; + + const { removeFilter, replaceFilter } = useSeriesFilters({ seriesId, series }); + + const { dataView } = useAppDataViewContext(series.dataType); + + if (filters.length === 0 || !dataView) { + return null; + } + + const btnProps = { + seriesId, + series, + dataView, + }; + + return ( + <> + + {filters.map( + ({ field, values = [], notValues = [], wildcards = [], notWildcards = [] }) => ( + + {values.length > 0 && ( + + { + replaceFilter({ + field, + values: [], + notValues, + wildcards, + notWildcards, + }); + }} + negate={false} + {...btnProps} + /> + + )} + {notValues.length > 0 && ( + + { + replaceFilter({ + field, + notValues: [], + values, + wildcards, + notWildcards, + }); + }} + {...btnProps} + /> + + )} + {wildcards.length > 0 && ( + + { + wildcards?.forEach((val) => { + removeFilter({ field, value: val, negate: false, isWildcard: true }); + }); + }} + {...btnProps} + /> + + )} + {notWildcards.length > 0 && ( + + { + notWildcards?.forEach((val) => { + removeFilter({ field, value: val, negate: true, isWildcard: true }); + }); + }} + {...btnProps} + /> + + )} + + ) + )} + + {(series.filters ?? []).length > 0 && ( + + { + setSeries(seriesId, { ...series, filters: undefined }); + }} + size="xs" + > + {i18n.translate('xpack.exploratoryView.expView.seriesEditor.clearFilter', { + defaultMessage: 'Clear filters', + })} + + + )} + + + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_actions.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_actions.test.tsx new file mode 100644 index 0000000000000..ec5c3b3c4eca0 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_actions.test.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; +import { SeriesActions } from './series_actions'; +import { mockUxSeries, render } from '../../rtl_helpers'; + +describe('SeriesActions', function () { + it('should contain an edit button', function () { + const { getByLabelText } = render(); + + expect(getByLabelText('Edit series')).toBeInTheDocument(); + }); + + it('should contain an actions button', function () { + const { getByLabelText } = render(); + + expect(getByLabelText('View series actions')).toBeInTheDocument(); + }); + + describe('action context menu', function () { + beforeEach(async () => { + render(); + + const actionsButton = screen.getByLabelText('View series actions'); + userEvent.click(actionsButton); + await waitForEuiPopoverOpen(); + }); + + it('should display the action list when the actions button is clicked', function () { + expect(screen.getByLabelText('Series actions list')).toBeVisible(); + }); + + it('should display a view transaction link', function () { + expect(screen.getByLabelText('View transaction in Discover')).toBeVisible(); + }); + + it('should display a hide series link', function () { + expect(screen.getByLabelText('Hide series')).toBeVisible(); + }); + + it('should display a duplicates series link', function () { + expect(screen.getByLabelText('Duplicate series')).toBeVisible(); + }); + + it('should display a remove series link', function () { + expect(screen.getByLabelText('Remove series')).toBeVisible(); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx new file mode 100644 index 0000000000000..a2e78fb36c41f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useCallback } from 'react'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiPopover, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { SeriesConfig, SeriesUrl } from '../../types'; +import { useDiscoverLink } from '../../hooks/use_discover_link'; +import { useAppDataViewContext } from '../../hooks/use_app_data_view'; + +interface Props { + seriesId: number; + series: SeriesUrl; + seriesConfig?: SeriesConfig; + onEditClick?: () => void; +} + +export function SeriesActions({ seriesId, series, seriesConfig, onEditClick }: Props) { + const { setSeries, removeSeries, allSeries } = useSeriesStorage(); + const [isPopoverOpen, setPopover] = useState(false); + + const { href: discoverHref } = useDiscoverLink({ series, seriesConfig }); + + const { dataViews } = useAppDataViewContext(); + + const dataView = dataViews?.[series.dataType]; + const deleteDisabled = seriesId === 0 && allSeries.length > 1; + + const copySeries = () => { + let copySeriesId: string = `${series.name}-copy`; + if (allSeries.find(({ name }) => name === copySeriesId)) { + copySeriesId = copySeriesId + allSeries.length; + } + setSeries(allSeries.length, { ...series, name: copySeriesId, breakdown: undefined }); + closePopover(); + }; + + const toggleSeries = () => { + if (series.hidden) { + setSeries(seriesId, { ...series, hidden: undefined }); + } else { + setSeries(seriesId, { ...series, hidden: true }); + } + closePopover(); + }; + + const closePopover = useCallback(() => { + setPopover(false); + }, [setPopover]); + + const onRemoveSeriesClick = useCallback(() => { + removeSeries(seriesId); + closePopover(); + }, [removeSeries, seriesId, closePopover]); + + const changePopoverVisibility = useCallback(() => { + setPopover(!isPopoverOpen); + }, [setPopover, isPopoverOpen]); + + const popoverButton = ( + + ); + + return ( + + + + + + + + + + + {VIEW_SAMPLE_DOCUMENTS_LABEL} + , + + {series.hidden ? SHOW_SERIES_LABEL : HIDE_SERIES_LABEL} + , + + {COPY_SERIES_LABEL} + , + + {DELETE_SERIES_LABEL} + , + ]} + /> + + + + ); +} + +const EDIT_SERIES_LABEL = i18n.translate('xpack.exploratoryView.seriesEditor.edit', { + defaultMessage: 'Edit series', +}); + +const HIDE_SERIES_LABEL = i18n.translate('xpack.exploratoryView.seriesEditor.hide', { + defaultMessage: 'Hide series', +}); + +const SHOW_SERIES_LABEL = i18n.translate('xpack.exploratoryView.seriesEditor.show', { + defaultMessage: 'Show series', +}); + +const COPY_SERIES_LABEL = i18n.translate('xpack.exploratoryView.seriesEditor.clone', { + defaultMessage: 'Duplicate series', +}); + +const DELETE_SERIES_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.seriesEditor.removeSeries', + { + defaultMessage: 'Remove series', + } +); + +const DELETE_SERIES_TOOLTIP_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.seriesEditor.removeSeriesDisabled', + { + defaultMessage: + 'Main series cannot be removed. Please remove all series below before you can remove this.', + } +); + +const VIEW_SAMPLE_DOCUMENTS_LABEL = i18n.translate( + 'xpack.exploratoryView.seriesEditor.sampleDocuments', + { + defaultMessage: 'View transaction in Discover', + } +); + +const POPOVER_BUTTON_LABEL = i18n.translate( + 'xpack.exploratoryView.seriesEditor.popoverButtonLabel', + { + defaultMessage: 'View series actions', + } +); + +const ACTIONS_CONTEXT_MENU_LABEL = i18n.translate( + 'xpack.exploratoryView.seriesEditor.actionsAriaContextLabel', + { + defaultMessage: 'Series actions list', + } +); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx new file mode 100644 index 0000000000000..cc84f64c2c7f0 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_filter.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, { useMemo } from 'react'; +import { EuiFilterGroup, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { FilterExpanded } from './filter_expanded'; +import { SeriesConfig, SeriesUrl } from '../../types'; +import { FieldLabels, LABEL_FIELDS_FILTER } from '../../configurations/constants/constants'; +import { SelectedFilters } from './selected_filters'; +import { LabelsFieldFilter } from '../components/labels_filter'; +import { URLSearch } from '../../components/url_search/url_search'; +import { TRANSACTION_URL } from '../../configurations/constants/elasticsearch_fieldnames'; + +interface Props { + seriesId: number; + seriesConfig: SeriesConfig; + series: SeriesUrl; +} + +export interface Field { + label: string; + field: string; + nestedField?: string; + isNegated?: boolean; +} + +export function SeriesFilter({ series, seriesConfig, seriesId }: Props) { + const options: Field[] = seriesConfig.filterFields + .filter((field) => field !== TRANSACTION_URL) + .map((field) => { + if (typeof field === 'string') { + return { label: seriesConfig.labels?.[field] ?? FieldLabels[field] ?? field, field }; + } + + return { + field: field.field, + nestedField: field.nested, + isNegated: field.isNegated, + label: (seriesConfig.labels?.[field.field] ?? FieldLabels[field.field]) || field.field, + }; + }); + + const hasUrlFilter = useMemo(() => { + return seriesConfig.filterFields.some((field) => { + if (typeof field === 'string') { + return field === TRANSACTION_URL; + } else if (field.field !== undefined) { + return field.field === TRANSACTION_URL; + } else { + return false; + } + }); + }, [seriesConfig]); + + return ( + <> + + {hasUrlFilter ? ( + + + + ) : null} + + + {options.map((opt) => + opt.field === LABEL_FIELDS_FILTER ? ( + + ) : ( + + ) + )} + + + + + + + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_info.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_info.tsx new file mode 100644 index 0000000000000..4c2e57e780550 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_info.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { SeriesConfig, SeriesUrl } from '../../types'; +import { SeriesColorPicker } from '../../components/series_color_picker'; +import { SeriesChartTypes } from './chart_type_select'; + +interface Props { + seriesId: number; + series: SeriesUrl; + seriesConfig?: SeriesConfig; +} + +export function SeriesInfo({ seriesId, series, seriesConfig }: Props) { + if (!seriesConfig) { + return null; + } + + return ( + + + + + + + + + ); + + return null; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_name.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_name.test.tsx new file mode 100644 index 0000000000000..cbd7efc42d964 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_name.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { mockUxSeries, render } from '../../rtl_helpers'; +import { SeriesName } from './series_name'; + +// ensures that fields appropriately match to their label +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + ...jest.requireActual('@elastic/eui/lib/services/accessibility/html_id_generator'), + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + +describe('SeriesName', function () { + it('should render properly', async function () { + render(); + + expect(screen.getByText(mockUxSeries.name)).toBeInTheDocument(); + }); + + it('should display input when editing name', async function () { + render(); + + let input = screen.queryByTestId('exploratoryViewSeriesNameInput') as HTMLInputElement; + + // read only + expect(input).not.toBeInTheDocument(); + + const editButton = screen.getByRole('button'); + // toggle editing + fireEvent.click(editButton); + + await waitFor(() => { + input = screen.getByTestId('exploratoryViewSeriesNameInput') as HTMLInputElement; + + expect(input).toBeInTheDocument(); + expect(input.value).toBe(mockUxSeries.name); + }); + + // toggle readonly + fireEvent.click(editButton); + + await waitFor(() => { + input = screen.queryByTestId('exploratoryViewSeriesNameInput') as HTMLInputElement; + + expect(screen.getByText(mockUxSeries.name)).toBeInTheDocument(); + expect(input).not.toBeInTheDocument(); + }); + }); + + it('should save name on enter key', async function () { + const newName = '-test-new-name'; + render(); + + let input = screen.queryByTestId('exploratoryViewSeriesNameInput') as HTMLInputElement; + + // read only + expect(input).not.toBeInTheDocument(); + + const editButton = screen.getByRole('button'); + // toggle editing + userEvent.click(editButton); + + await waitFor(() => { + input = screen.getByTestId('exploratoryViewSeriesNameInput') as HTMLInputElement; + + expect(input).toBeInTheDocument(); + }); + + userEvent.click(input); + userEvent.type(input, newName); + + // submit + userEvent.keyboard('{enter}'); + + await waitFor(() => { + input = screen.queryByTestId('exploratoryViewSeriesNameInput') as HTMLInputElement; + + expect(screen.getByText(`${mockUxSeries.name}${newName}`)).toBeInTheDocument(); + expect(input).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_name.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_name.tsx new file mode 100644 index 0000000000000..ad2b1c1823f04 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/series_name.tsx @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, ChangeEvent, useEffect, useRef, KeyboardEventHandler } from 'react'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { + EuiFieldText, + EuiText, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiOutsideClickDetector, +} from '@elastic/eui'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { SeriesUrl } from '../../types'; + +interface Props { + seriesId: number; + series: SeriesUrl; +} + +export const StyledText = styled(EuiText)` + &.euiText.euiText--constrainedWidth { + max-width: 200px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } +`; + +export function SeriesName({ series, seriesId }: Props) { + const { setSeries } = useSeriesStorage(); + + const [value, setValue] = useState(series.name); + const [isEditingEnabled, setIsEditingEnabled] = useState(false); + const inputRef = useRef(null); + const buttonRef = useRef(null); + + const onChange = (e: ChangeEvent) => { + setValue(e.target.value); + }; + + const onSave = () => { + if (value !== series.name) { + setSeries(seriesId, { ...series, name: value }); + } + }; + + const onOutsideClick = (event: Event) => { + if (event.target !== buttonRef.current) { + setIsEditingEnabled(false); + onSave(); + } + }; + + const onKeyDown: KeyboardEventHandler = (event) => { + if (event.key === 'Enter') { + setIsEditingEnabled(false); + onSave(); + } + }; + + useEffect(() => { + setValue(series.name); + }, [series.name]); + + useEffect(() => { + if (isEditingEnabled && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditingEnabled, inputRef]); + + return ( + + {isEditingEnabled ? ( + + + + + + ) : ( + + {value} + + )} + + setIsEditingEnabled(!isEditingEnabled)} + iconType="pencil" + aria-label={i18n.translate('xpack.exploratoryView.expView.seriesEditor.editName', { + defaultMessage: 'Edit name', + })} + color="text" + buttonRef={buttonRef} + /> + + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/text_report_definition_field.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/text_report_definition_field.tsx new file mode 100644 index 0000000000000..80a2b5681e677 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/columns/text_report_definition_field.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFieldText, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SeriesConfig, SeriesUrl } from '../../types'; + +interface Props { + seriesId: number; + series: SeriesUrl; + field: string; + seriesConfig: SeriesConfig; + onChange: (field: string, value: string) => void; +} + +export function TextReportDefinitionField({ series, field, seriesConfig, onChange }: Props) { + const { textReportDefinitions: selectedTextReportDefinitions = {} } = series; + const { labels } = seriesConfig; + const label = labels[field] ?? field; + + return ( + + onChange(field, e.target.value)} + compressed={false} + /> + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/components/filter_values_list.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/components/filter_values_list.tsx new file mode 100644 index 0000000000000..9712428fc0cb0 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/components/filter_values_list.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFieldSearch, EuiFilterGroup, EuiProgress, EuiSpacer, EuiText } from '@elastic/eui'; +import React, { Fragment, useState } from 'react'; +import { rgba } from 'polished'; +import styled from 'styled-components'; +import { map } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { FilterValueButton } from '../columns/filter_value_btn'; +import { FilterProps, NestedFilterOpen } from '../columns/filter_expanded'; +import { UrlFilter } from '../../types'; +import { ListItem } from '../../../../../hooks/use_values_list'; + +interface Props extends FilterProps { + values: ListItem[]; + field: string; + query: string; + loading?: boolean; + setQuery: (q: string) => void; +} + +export function FilterValuesList({ + field, + values, + query, + setQuery, + label, + loading, + isNegated, + nestedField, + series, + seriesId, +}: Props) { + const [isNestedOpen, setIsNestedOpen] = useState({ value: '', negate: false }); + + const displayValues = map(values, 'label').filter((opt) => + opt.toLowerCase().includes(query.toLowerCase()) + ); + + const filters = series?.filters ?? []; + + const currFilter: UrlFilter | undefined = filters.find(({ field: fd }) => field === fd); + + const btnProps = { + field, + nestedField, + seriesId, + series, + isNestedOpen, + setIsNestedOpen, + }; + + return ( + + { + setQuery(evt.target.value); + }} + placeholder={getSearchLabel(label)} + /> + + + {loading && ( +
+ +
+ )} + {displayValues.length === 0 && !loading && ( + {NO_RESULT_FOUND} + )} + {displayValues.map((opt) => ( + + + {isNegated !== false && ( + + )} + + + + + ))} +
+
+ ); +} + +const NO_RESULT_FOUND = i18n.translate('xpack.exploratoryView.filters.expanded.noFilter', { + defaultMessage: 'No filters found.', +}); + +const getSearchLabel = (label: string) => + i18n.translate('xpack.exploratoryView.filters.expanded.search', { + defaultMessage: 'Search for {label}', + values: { label }, + }); + +const ListWrapper = euiStyled.div` + height: 370px; + overflow-y: auto; + &::-webkit-scrollbar { + height: ${({ theme }) => theme.eui.euiScrollBar}; + width: ${({ theme }) => theme.eui.euiScrollBar}; + } + &::-webkit-scrollbar-thumb { + background-clip: content-box; + background-color: ${({ theme }) => rgba(theme.eui.euiColorDarkShade, 0.5)}; + border: ${({ theme }) => theme.eui.euiScrollBarCorner} solid transparent; + } + &::-webkit-scrollbar-corner, + &::-webkit-scrollbar-track { + background-color: transparent; + } +`; + +const Wrapper = styled.div` + width: 400px; +`; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/components/labels_filter.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/components/labels_filter.tsx new file mode 100644 index 0000000000000..5d8d7f24970c1 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/components/labels_filter.tsx @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { + EuiPopoverTitle, + EuiFilterButton, + EuiPopover, + EuiIcon, + EuiButtonEmpty, + EuiSelectableOption, +} from '@elastic/eui'; + +import { EuiSelectable } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { FilterProps } from '../columns/filter_expanded'; +import { useAppDataViewContext } from '../../hooks/use_app_data_view'; +import { FilterValuesList } from './filter_values_list'; +import { useFilterValues } from '../use_filter_values'; + +export function LabelsFieldFilter(props: FilterProps) { + const { series } = props; + + const [query, setQuery] = useState(''); + + const { dataView } = useAppDataViewContext(series.dataType); + + const labelFields = dataView?.fields.filter((field) => field.name.startsWith('labels.')); + + const [isPopoverOpen, setPopover] = useState(false); + + const onButtonClick = () => { + setPopover(!isPopoverOpen); + }; + + const button = ( + + {LABELS_LABEL} + + ); + + const [selectedLabel, setSelectedLabel] = useState(''); + + const { values, loading } = useFilterValues({ ...props, field: selectedLabel }, query); + + const labelFieldOptions: EuiSelectableOption[] = (labelFields ?? []).map((field) => { + return { + label: field.name, + searchableLabel: field.name, + append: , + showIcons: false, + }; + }); + + labelFieldOptions.unshift({ + label: LABELS_FIELDS_LABEL, + isGroupLabel: true, + }); + + const closePopover = () => { + setPopover(false); + setSelectedLabel(''); + }; + + return ( + + {selectedLabel ? ( + <> + + setSelectedLabel('')} + > + {BACK_TO_LABEL} + + + + + ) : ( + { + const checked = optionsChange.find((option) => option.checked === 'on'); + setSelectedLabel(checked?.label ?? ''); + }} + listProps={{ + onFocusBadge: false, + }} + height={450} + > + {(list, search) => ( +
+ {search} + {list} +
+ )} +
+ )} +
+ ); +} + +const LABELS_LABEL = i18n.translate('xpack.exploratoryView.filters.expanded.labels.label', { + defaultMessage: 'Labels', +}); + +const LABELS_FIELDS_LABEL = i18n.translate('xpack.exploratoryView.filters.expanded.labels.fields', { + defaultMessage: 'Label fields', +}); + +const BACK_TO_LABEL = i18n.translate('xpack.exploratoryView.filters.expanded.labels.backTo', { + defaultMessage: 'Back to labels', +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/expanded_series_row.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/expanded_series_row.test.tsx new file mode 100644 index 0000000000000..5d70e42808ea9 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/expanded_series_row.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import { ExpandedSeriesRow } from './expanded_series_row'; +import { mockDataView, mockUxSeries, render } from '../rtl_helpers'; +import { getDefaultConfigs } from '../configurations/default_configs'; +import { PERCENTILE } from '../configurations/constants'; +import { obsvReportConfigMap } from '../obsv_exploratory_view'; + +describe('ExpandedSeriesRow', function () { + const dataViewSeries = getDefaultConfigs({ + reportConfigMap: obsvReportConfigMap, + reportType: 'kpi-over-time', + dataView: mockDataView, + dataType: 'ux', + }); + + it('should render properly', async function () { + render(); + + expect(screen.getByText('Breakdown by')).toBeInTheDocument(); + expect(screen.getByText('Operation')).toBeInTheDocument(); + }); + + it('should not display operation field when percentile breakdowns are applied', async function () { + render( + + ); + + expect(screen.queryByText('Operation')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/expanded_series_row.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/expanded_series_row.tsx new file mode 100644 index 0000000000000..0caea7ee4773e --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/expanded_series_row.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiHorizontalRule } from '@elastic/eui'; +import { SeriesConfig, SeriesUrl } from '../types'; +import { PERCENTILE } from '../configurations/constants'; +import { ReportDefinitionCol } from './columns/report_definition_col'; +import { OperationTypeSelect } from './columns/operation_type_select'; +import { parseCustomFieldName } from '../configurations/lens_attributes'; +import { SeriesFilter } from './columns/series_filter'; +import { DatePickerCol } from './columns/date_picker_col'; +import { Breakdowns } from './breakdown/breakdowns'; +import { LabelsBreakdown } from './breakdown/label_breakdown'; + +function getColumnType(seriesConfig: SeriesConfig, selectedMetricField?: string) { + const metricOption = parseCustomFieldName(seriesConfig, selectedMetricField); + + if (!Array.isArray(metricOption)) { + return metricOption?.columnType; + } +} + +interface Props { + seriesId: number; + series: SeriesUrl; + seriesConfig?: SeriesConfig; +} +export function ExpandedSeriesRow(seriesProps: Props) { + const { seriesConfig, series, seriesId } = seriesProps; + + if (!seriesConfig) { + return null; + } + + const { selectedMetricField } = series ?? {}; + + const { hasOperationType, yAxisColumns } = seriesConfig; + + const columnType = getColumnType(seriesConfig, selectedMetricField); + + // if the breakdown field is percentiles, we can't apply further operations + const hasPercentileBreakdown = series.breakdown === PERCENTILE; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + {(hasOperationType || (columnType === 'operation' && !hasPercentileBreakdown)) && ( + + + + + + )} + +
+ ); +} + +const BREAKDOWN_BY_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.seriesBuilder.breakdownBy', + { + defaultMessage: 'Breakdown by', + } +); + +const FILTERS_LABEL = i18n.translate('xpack.exploratoryView.expView.seriesBuilder.selectFilters', { + defaultMessage: 'Filters', +}); + +const OPERATION_LABEL = i18n.translate('xpack.exploratoryView.expView.seriesBuilder.operation', { + defaultMessage: 'Operation', +}); + +const DATE_LABEL = i18n.translate('xpack.exploratoryView.expView.seriesBuilder.date', { + defaultMessage: 'Date', +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/report_metric_options.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/report_metric_options.test.tsx new file mode 100644 index 0000000000000..63725346ba18b --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/report_metric_options.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { mockAppDataView, mockDataView, mockUxSeries, render } from '../rtl_helpers'; +import { getDefaultConfigs } from '../configurations/default_configs'; +import { PERCENTILE } from '../configurations/constants'; +import { ReportMetricOptions } from './report_metric_options'; +import { obsvReportConfigMap } from '../obsv_exploratory_view'; + +describe('ReportMetricOptions', function () { + const dataViewSeries = getDefaultConfigs({ + dataType: 'ux', + reportType: 'kpi-over-time', + dataView: mockDataView, + reportConfigMap: obsvReportConfigMap, + }); + + it('should render properly', async function () { + render( + + ); + + expect(await screen.findByText('No data available')).toBeInTheDocument(); + }); + + it('should display loading if index pattern is not available and is loading', async function () { + mockAppDataView({ loading: true, dataViews: undefined }); + const { container } = render( + + ); + + expect(container.getElementsByClassName('euiLoadingSpinner').length).toBe(1); + }); + + it('should not display loading if index pattern is already loaded', async function () { + mockAppDataView({ loading: true }); + render( + + ); + + expect(await screen.findByText('Page load time')).toBeInTheDocument(); + }); + + it('should include a tooltip for the report metric', async function () { + mockAppDataView({ loading: false }); + const { getByText, findByText } = render( + + ); + + userEvent.hover(getByText('Page load time')); + + // The tooltip from EUI takes 250ms to appear, so we must + // use a `find*` query to asynchronously poll for it. + expect(await findByText('Report metric')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/report_metric_options.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/report_metric_options.tsx new file mode 100644 index 0000000000000..ca2654f40921d --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/report_metric_options.tsx @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useCallback } from 'react'; +import { + EuiToolTip, + EuiPopover, + EuiButton, + EuiListGroup, + EuiListGroupItem, + EuiBadge, + EuiText, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useSeriesStorage } from '../hooks/use_series_storage'; +import { SeriesConfig, SeriesUrl } from '../types'; +import { useAppDataViewContext } from '../hooks/use_app_data_view'; +import { RECORDS_FIELD, RECORDS_PERCENTAGE_FIELD } from '../configurations/constants'; + +interface Props { + seriesId: number; + series: SeriesUrl; + defaultValue?: string; + seriesConfig?: SeriesConfig; +} + +export function ReportMetricOptions({ seriesId, series, seriesConfig }: Props) { + const { setSeries } = useSeriesStorage(); + const [showOptions, setShowOptions] = useState(false); + const metricOptions = seriesConfig?.metricOptions; + + const { dataViews, dataViewErrors, loading } = useAppDataViewContext(); + + const onChange = (value?: string) => { + setSeries(seriesId, { + ...series, + selectedMetricField: value, + }); + }; + + const focusButton = useCallback((ref: HTMLButtonElement) => { + ref?.focus(); + }, []); + + if (!series.dataType) { + return null; + } + + const dataView = dataViews?.[series.dataType]; + const dataViewError = dataViewErrors?.[series.dataType]; + + const options = (metricOptions ?? []).map(({ label, field, id }) => { + let disabled = false; + + if (field !== RECORDS_FIELD && field !== RECORDS_PERCENTAGE_FIELD && field) { + disabled = !Boolean(dataView?.getFieldByName(field)); + } + return { + disabled, + value: field || id, + dropdownDisplay: disabled ? ( + {field}, + }} + /> + } + > + {label} + + ) : ( + label + ), + inputDisplay: label, + }; + }); + + if (dataViewError && !dataView && !loading) { + // TODO: Add a link to docs to explain how to add index patterns + return ( + + {dataViewError.body?.error === 'Forbidden' || + dataViewError.name === 'DataViewInsufficientAccessError' + ? NO_PERMISSIONS + : dataViewError.body?.message} + + ); + } + + if (!dataView && !loading) { + return {NO_DATA_AVAILABLE}; + } + + return ( + <> + {!series.selectedMetricField && ( + setShowOptions((prevState) => !prevState)} + fill + size="s" + isLoading={!dataView && loading} + buttonRef={focusButton} + > + {SELECT_REPORT_METRIC_LABEL} + + } + isOpen={showOptions} + closePopover={() => setShowOptions((prevState) => !prevState)} + > + + {options.map((option) => ( + onChange(option.value)} + label={option.dropdownDisplay} + isDisabled={option.disabled} + /> + ))} + + + )} + {series.selectedMetricField && + (dataView ? ( + + onChange(undefined)} + iconOnClickAriaLabel={REMOVE_REPORT_METRIC_LABEL} + > + {seriesConfig?.metricOptions?.find( + (option) => + option.id === series.selectedMetricField || + option.field === series.selectedMetricField + )?.label ?? series.selectedMetricField} + + + ) : ( + + ))} + + ); +} + +const SELECT_REPORT_METRIC_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.seriesEditor.selectReportMetric', + { + defaultMessage: 'Select report metric', + } +); + +const REMOVE_REPORT_METRIC_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.seriesEditor.removeReportMetric', + { + defaultMessage: 'Remove report metric', + } +); + +const NO_DATA_AVAILABLE = i18n.translate('xpack.exploratoryView.expView.seriesEditor.noData', { + defaultMessage: 'No data available', +}); + +const NO_PERMISSIONS = i18n.translate('xpack.exploratoryView.expView.seriesEditor.noPermissions', { + defaultMessage: + "Unable to create Data View. You don't have the required permission, please contact your admin.", +}); + +const REPORT_METRIC_TOOLTIP = i18n.translate( + 'xpack.exploratoryView.expView.seriesEditor.reportMetricTooltip', + { + defaultMessage: 'Report metric', + } +); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/series.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/series.tsx new file mode 100644 index 0000000000000..7be69c2a0969f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/series.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexItem, EuiFlexGroup, EuiPanel, EuiAccordion, EuiSpacer } from '@elastic/eui'; +import { BuilderItem } from '../types'; +import { SeriesActions } from './columns/series_actions'; +import { SeriesInfo } from './columns/series_info'; +import { DataTypesSelect } from './columns/data_type_select'; +import { IncompleteBadge } from './columns/incomplete_badge'; +import { ExpandedSeriesRow } from './expanded_series_row'; +import { SeriesName } from './columns/series_name'; +import { ReportMetricOptions } from './report_metric_options'; + +const StyledAccordion = styled(EuiAccordion)` + .euiAccordion__button { + width: auto; + flex-grow: 0; + } + + .euiAccordion__optionalAction { + flex-grow: 1; + flex-shrink: 1; + } + + .euiAccordion__childWrapper { + overflow: visible; + } +`; + +interface Props { + item: BuilderItem; + isExpanded: boolean; + toggleExpanded: () => void; +} + +export function Series({ item, isExpanded, toggleExpanded }: Props) { + const { id } = item; + const seriesProps = { + ...item, + seriesId: id, + }; + + const [isExpandedOnce, setIsExpandedOnce] = useState(false); + + useEffect(() => { + if (isExpanded) { + setIsExpandedOnce(true); + } + }, [isExpanded]); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + > + + + {isExpandedOnce && } + + + + ); +} + +export const ACCORDION_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.seriesBuilder.accordion.label', + { + defaultMessage: 'Toggle series information', + } +); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/series_editor.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/series_editor.tsx new file mode 100644 index 0000000000000..3a267433e70a3 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/series_editor.tsx @@ -0,0 +1,248 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSpacer, EuiFormRow, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { rgba } from 'polished'; +import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { AppDataType, ReportViewType, BuilderItem } from '../types'; +import { SeriesContextValue, useSeriesStorage } from '../hooks/use_series_storage'; +import { DataViewState, useAppDataViewContext } from '../hooks/use_app_data_view'; +import { getDefaultConfigs } from '../configurations/default_configs'; +import { ReportTypesSelect } from './columns/report_type_select'; +import { ViewActions } from '../views/view_actions'; +import { Series } from './series'; +import { ReportConfigMap, useExploratoryView } from '../contexts/exploratory_view_config'; + +export interface ReportTypeItem { + id: string; + reportType: ReportViewType; + label: string; +} + +type ExpandedRowMap = Record; + +export const getSeriesToEdit = ({ + dataViews, + allSeries, + reportType, + reportConfigMap, +}: { + allSeries: SeriesContextValue['allSeries']; + dataViews: DataViewState; + reportType: ReportViewType; + reportConfigMap: ReportConfigMap; +}): BuilderItem[] => { + const getDataViewSeries = (dataType: AppDataType) => { + if (dataViews?.[dataType]) { + return getDefaultConfigs({ + dataType, + reportType, + reportConfigMap, + dataView: dataViews[dataType], + }); + } + }; + + return allSeries.map((series, seriesIndex) => { + const seriesConfig = getDataViewSeries(series.dataType); + + return { id: seriesIndex, series, seriesConfig }; + }); +}; + +export const SeriesEditor = React.memo(function () { + const [editorItems, setEditorItems] = useState([]); + + const { getSeries, allSeries, reportType } = useSeriesStorage(); + + const { loading, dataViews } = useAppDataViewContext(); + + const { reportConfigMap, setIsEditMode } = useExploratoryView(); + + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState>({}); + + const [{ prevCount, curCount }, setSeriesCount] = useState<{ + prevCount?: number; + curCount: number; + }>({ + curCount: allSeries.length, + }); + + useEffect(() => { + setIsEditMode?.(Object.keys(itemIdToExpandedRowMap).length > 0); + }, [itemIdToExpandedRowMap, setIsEditMode]); + + useEffect(() => { + setSeriesCount((oldParams) => ({ prevCount: oldParams.curCount, curCount: allSeries.length })); + if (typeof prevCount !== 'undefined' && !isNaN(prevCount) && prevCount < curCount) { + setItemIdToExpandedRowMap({}); + } + }, [allSeries.length, curCount, prevCount]); + + useEffect(() => { + const newExpandRows: ExpandedRowMap = {}; + + setEditorItems((prevState) => { + const newEditorItems = getSeriesToEdit({ + reportType, + allSeries, + dataViews, + reportConfigMap, + }); + + newEditorItems.forEach(({ series, id }) => { + const prevSeriesItem = prevState.find(({ id: prevId }) => prevId === id); + if ( + prevSeriesItem && + series.selectedMetricField && + prevSeriesItem.series.selectedMetricField !== series.selectedMetricField + ) { + newExpandRows[id] = true; + } + }); + return [...newEditorItems]; + }); + + setItemIdToExpandedRowMap((prevState) => { + return { ...prevState, ...newExpandRows }; + }); + }, [allSeries, getSeries, dataViews, loading, reportConfigMap, reportType]); + + const toggleDetails = (item: BuilderItem) => { + const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; + if (itemIdToExpandedRowMapValues[item.id]) { + delete itemIdToExpandedRowMapValues[item.id]; + } else { + itemIdToExpandedRowMapValues[item.id] = true; + } + setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); + }; + + return ( + + + + + + + + + + + setItemIdToExpandedRowMap({})} /> + + + + + {editorItems.map((item, index) => ( +
+ toggleDetails(item)} + isExpanded={itemIdToExpandedRowMap[item.id]} + /> + {index + 1 !== editorItems.length && } +
+ ))} +
+
+ ); +}); + +const Wrapper = euiStyled.div` + &::-webkit-scrollbar { + height: ${({ theme }) => theme.eui.euiScrollBar}; + width: ${({ theme }) => theme.eui.euiScrollBar}; + } + &::-webkit-scrollbar-thumb { + background-clip: content-box; + background-color: ${({ theme }) => rgba(theme.eui.euiColorDarkShade, 0.5)}; + border: ${({ theme }) => theme.eui.euiScrollBarCorner} solid transparent; + } + &::-webkit-scrollbar-corner, + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &&& { + .euiTableRow-isExpandedRow .euiTableRowCell { + border-top: none; + background-color: #FFFFFF; + border-bottom: 2px solid #d3dae6; + border-right: 2px solid rgb(211, 218, 230); + border-left: 2px solid rgb(211, 218, 230); + } + + .isExpanded { + border-right: 2px solid rgb(211, 218, 230); + border-left: 2px solid rgb(211, 218, 230); + .euiTableRowCell { + border-bottom: none; + } + } + .isIncomplete .euiTableRowCell { + background-color: rgba(254, 197, 20, 0.1); + } + } +`; + +const SectionHeaderBackground = euiStyled.div` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 56px; + background-color: ${({ theme }) => theme.eui.euiPageBackgroundColor}; + border-bottom: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; + z-index: 90; +`; + +const StickyFlexGroup = euiStyled(EuiFlexGroup)` + position: sticky; + top: 0; + z-index: 100; + padding: 0; +`; + +const EditorRowsWrapper = euiStyled.div` + margin: ${({ theme }) => theme.eui.euiSizeM} 0; +`; + +export const LOADING_VIEW = i18n.translate( + 'xpack.exploratoryView.expView.seriesBuilder.loadingView', + { + defaultMessage: 'Loading view ...', + } +); + +export const SELECT_REPORT_TYPE = i18n.translate( + 'xpack.exploratoryView.expView.seriesBuilder.selectReportType', + { + defaultMessage: 'No report type selected', + } +); + +export const REPORT_TYPE_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.seriesBuilder.reportType', + { + defaultMessage: 'Report type', + } +); + +export const REPORT_TYPE_ARIA_LABEL = i18n.translate( + 'xpack.exploratoryView.expView.seriesBuilder.reportType.aria', + { + defaultMessage: 'This select allows you to choose the type of report you wish to create', + } +); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/use_filter_values.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/use_filter_values.ts new file mode 100644 index 0000000000000..8a6653dd62c1b --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/series_editor/use_filter_values.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ExistsFilter, isExistsFilter } from '@kbn/es-query'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { ESFilter } from '@kbn/es-types'; +import { PersistableFilter } from '@kbn/lens-plugin/common'; +import { useValuesList } from '../../../../hooks/use_values_list'; +import { FilterProps } from './columns/filter_expanded'; +import { useAppDataViewContext } from '../hooks/use_app_data_view'; + +export function useFilterValues( + { field, series, baseFilters, label }: FilterProps, + query?: string +) { + const { dataViews } = useAppDataViewContext(series.dataType); + + const queryFilters: ESFilter[] = []; + + baseFilters?.forEach((qFilter: PersistableFilter | ExistsFilter) => { + if (qFilter.query) { + queryFilters.push(qFilter.query); + } + if (isExistsFilter(qFilter)) { + queryFilters.push({ exists: qFilter.query.exists } as estypes.QueryDslQueryContainer); + } + }); + + return useValuesList({ + query, + label: label ?? field, + sourceField: field, + time: series.time, + keepHistory: true, + filters: queryFilters, + dataViewTitle: dataViews[series.dataType]?.title, + }); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/types.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/types.ts new file mode 100644 index 0000000000000..690f00e19fec1 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/types.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PaletteOutput } from '@kbn/coloring'; +import type { ExistsFilter, PhraseFilter } from '@kbn/es-query'; +import type { + LastValueIndexPatternColumn, + DateHistogramIndexPatternColumn, + FieldBasedIndexPatternColumn, + SeriesType, + OperationType, + YConfig, + MetricState, +} from '@kbn/lens-plugin/public'; + +import type { PersistableFilter } from '@kbn/lens-plugin/common'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { + FieldFormatParams as BaseFieldFormatParams, + SerializedFieldFormat, +} from '@kbn/field-formats-plugin/common'; +import { TermsIndexPatternColumn } from '@kbn/lens-plugin/public'; +import { FORMULA_COLUMN } from './configurations/constants'; + +export const ReportViewTypes = { + dist: 'data-distribution', + kpi: 'kpi-over-time', + cwv: 'core-web-vitals', + mdd: 'device-data-distribution', + smt: 'single-metric', + htm: 'heatmap', +} as const; + +type ValueOf = T[keyof T]; + +export type ReportViewTypeId = keyof typeof ReportViewTypes; + +export type ReportViewType = ValueOf; + +export interface ColumnFilter { + language: 'kuery'; + query: string; +} + +export interface ParamFilter { + label: string; + input: ColumnFilter; +} + +export interface MetricOption { + id: string; + field?: string; + label: string; + description?: string; + columnType?: + | 'range' + | 'operation' + | 'FILTER_RECORDS' + | 'TERMS_COLUMN' + | 'unique_count' + | typeof FORMULA_COLUMN; + columnFilters?: ColumnFilter[]; + columnFilter?: ColumnFilter; + paramFilters?: ParamFilter[]; + timeScale?: string; + showPercentileAnnotations?: boolean; + formula?: string; + metricStateOptions?: Pick; + palette?: PaletteOutput; + format?: 'percent' | 'number'; + emptyAsNull?: boolean; + timestampField?: string; +} + +export interface SeriesConfig { + reportType: ReportViewType | string; + xAxisColumn: Partial | Partial; + yAxisColumns: Array>; + breakdownFields: string[]; + defaultSeriesType: SeriesType; + filterFields: Array; + seriesTypes: SeriesType[]; + baseFilters?: Array; + definitionFields: Array< + | string + | { + field: string; + nested?: string; + singleSelection?: boolean; + filters?: Array; + } + >; + textDefinitionFields?: string[]; + metricOptions?: Array< + | MetricOption + | { id: string; field?: string; label: string; items: MetricOption[]; columnType?: string } + >; + labels: Record; + hasOperationType: boolean; + palette?: PaletteOutput; + yTitle?: string; + yConfig?: YConfig[]; + query?: { query: string; language: 'kuery' }; +} + +export type URLReportDefinition = Record; +export type URLTextReportDefinition = Record; + +export interface SeriesUrl { + name: string; + time: { + to: string; + from: string; + }; + breakdown?: string; + filters?: UrlFilter[]; + seriesType?: SeriesType; + operationType?: OperationType; + dataType: AppDataType; + reportDefinitions?: URLReportDefinition; + textReportDefinitions?: URLTextReportDefinition; + selectedMetricField?: string; + hidden?: boolean; + showPercentileAnnotations?: boolean; + color?: string; +} + +export interface UrlFilter { + field: string; + values?: Array; + notValues?: Array; + wildcards?: string[]; + notWildcards?: string[]; +} + +export interface ConfigProps { + dataView?: DataView; + series?: SeriesUrl; + spaceId?: string; +} + +interface FormatType extends SerializedFieldFormat { + id: 'duration' | 'number' | 'bytes' | 'percent'; +} + +export type AppDataType = + | 'synthetics' + | 'uptime' + | 'ux' + | 'infra_logs' + | 'infra_metrics' + | 'apm' + | 'mobile' + | 'alerts'; + +type InputFormat = 'microseconds' | 'milliseconds' | 'seconds'; +type OutputFormat = 'asSeconds' | 'asMilliseconds' | 'humanize' | 'humanizePrecise'; + +export interface FieldFormatParams extends BaseFieldFormatParams { + inputFormat?: InputFormat; + outputFormat?: OutputFormat; + outputPrecision?: number; + showSuffix?: boolean; + useShortSuffix?: boolean; +} + +export interface FieldFormat { + field: string; + format: FormatType; +} + +export interface BuilderItem { + id: number; + series: SeriesUrl; + seriesConfig?: SeriesConfig; +} + +export type SupportedOperations = 'average' | 'median' | 'sum' | 'unique_count' | 'min' | 'max'; + +type TermColumnParams = TermsIndexPatternColumn['params']; + +export type TermColumnParamsOrderBy = TermColumnParams['orderBy']; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/utils/stringify_kueries.test.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/utils/stringify_kueries.test.ts new file mode 100644 index 0000000000000..c278483f87b08 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/utils/stringify_kueries.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { urlFiltersToKueryString } from './stringify_kueries'; +import { UrlFilter } from '../types'; +import { USER_AGENT_NAME } from '../configurations/constants/elasticsearch_fieldnames'; + +describe('stringifyKueries', () => { + let filters: UrlFilter[]; + beforeEach(() => { + filters = [ + { + field: USER_AGENT_NAME, + values: ['Chrome', 'Firefox'], + notValues: [], + }, + ]; + }); + + it('stringifies the current values', () => { + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"user_agent.name: (\\"Chrome\\" or \\"Firefox\\")"` + ); + }); + + it('correctly stringifies a single value', () => { + filters = [ + { + field: USER_AGENT_NAME, + values: ['Chrome'], + notValues: [], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"user_agent.name: \\"Chrome\\""` + ); + }); + + it('returns an empty string for an empty array', () => { + expect(urlFiltersToKueryString([])).toMatchInlineSnapshot(`""`); + }); + + it('returns an empty string for an empty value', () => { + filters = [ + { + field: USER_AGENT_NAME, + values: [], + notValues: [], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot(`""`); + }); + + it('adds quotations if the value contains a space', () => { + filters = [ + { + field: USER_AGENT_NAME, + values: ['Google Chrome'], + notValues: [], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"user_agent.name: \\"Google Chrome\\""` + ); + }); + + it('adds quotations inside parens if there are values containing spaces', () => { + filters = [ + { + field: USER_AGENT_NAME, + values: ['Google Chrome'], + notValues: ['Apple Safari'], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"user_agent.name: \\"Google Chrome\\" and not (user_agent.name: \\"Apple Safari\\")"` + ); + }); + + it('handles parens for values with greater than 2 items', () => { + filters = [ + { + field: USER_AGENT_NAME, + values: ['Chrome', 'Firefox', 'Safari', 'Opera'], + notValues: ['Safari'], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"user_agent.name: (\\"Chrome\\" or \\"Firefox\\" or \\"Safari\\" or \\"Opera\\") and not (user_agent.name: \\"Safari\\")"` + ); + }); + + it('handles colon characters in values', () => { + filters = [ + { + field: 'url', + values: ['https://elastic.co', 'https://example.com'], + notValues: [], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"url: (\\"https://elastic.co\\" or \\"https://example.com\\")"` + ); + }); + + it('handles precending empty array', () => { + filters = [ + { + field: 'url', + values: ['https://elastic.co', 'https://example.com'], + notValues: [], + }, + { + field: USER_AGENT_NAME, + values: [], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"url: (\\"https://elastic.co\\" or \\"https://example.com\\")"` + ); + }); + + it('handles skipped empty arrays', () => { + filters = [ + { + field: 'url', + values: ['https://elastic.co', 'https://example.com'], + notValues: [], + }, + { + field: USER_AGENT_NAME, + values: [], + }, + { + field: 'url', + values: ['https://elastic.co', 'https://example.com'], + notValues: [], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"url: (\\"https://elastic.co\\" or \\"https://example.com\\") and url: (\\"https://elastic.co\\" or \\"https://example.com\\")"` + ); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/utils/stringify_kueries.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/utils/stringify_kueries.ts new file mode 100644 index 0000000000000..aee60118bc7e4 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/utils/stringify_kueries.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { UrlFilter } from '../types'; + +/** + * Extract a map's keys to an array, then map those keys to a string per key. + * The strings contain all of the values chosen for the given field (which is also the key value). + * Reduce the list of query strings to a singular string, with AND operators between. + */ + +const buildOrCondition = (values: string[]) => { + if (values.length === 1) { + return `${values.join(' or ')}`; + } + return `(${values.join(' or ')})`; +}; + +function addSlashes(str: string | number) { + return (str + '').replace(/[\\"']/g, '\\$&').replace(/\u0000/g, '\\0'); +} + +export const urlFiltersToKueryString = (urlFilters: UrlFilter[]): string => { + let kueryString = ''; + + urlFilters.forEach(({ field, values, notValues, wildcards, notWildcards }) => { + const valuesT = values?.map((val) => `"${addSlashes(val)}"`); + const notValuesT = notValues?.map((val) => `"${addSlashes(val)}"`); + const wildcardsT = wildcards?.map((val) => `*${val}*`); + const notWildcardsT = notWildcards?.map((val) => `*${val}*`); + + if (valuesT && valuesT?.length > 0) { + if (kueryString.length > 0) { + kueryString += ' and '; + } + kueryString += `${field}: ${buildOrCondition(valuesT)}`; + } + + if (notValuesT && notValuesT?.length > 0) { + if (kueryString.length > 0) { + kueryString += ' and '; + } + kueryString += `not (${field}: ${buildOrCondition(notValuesT)})`; + } + if (wildcardsT && wildcardsT?.length > 0) { + if (kueryString.length > 0) { + kueryString += ' and '; + } + kueryString += `(${field}: ${buildOrCondition(wildcardsT)})`; + } + if (notWildcardsT && notWildcardsT?.length > 0) { + if (kueryString.length > 0) { + kueryString += ' and '; + } + kueryString += `(${field}: ${buildOrCondition(notWildcardsT)})`; + } + }); + + return kueryString; +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/utils/telemetry.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/utils/telemetry.test.tsx new file mode 100644 index 0000000000000..cf24cd47d9d10 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/utils/telemetry.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { AppDataType } from '../types'; +import { trackTelemetryOnApply, trackTelemetryOnLoad } from './telemetry'; + +const mockMultipleSeries = [ + { + name: 'performance-distribution', + dataType: 'ux' as AppDataType, + breakdown: 'user_agent.name', + time: { from: 'now-15m', to: 'now' }, + filters: [ + { + field: 'url.full', + value: 'https://elastic.co', + }, + ], + selectedMetricField: 'transaction.duration.us', + }, + { + name: 'kpi-over-time', + dataType: 'synthetics' as AppDataType, + breakdown: 'user_agent.name', + time: { from: 'now-15m', to: 'now' }, + filters: [ + { + field: 'monitor.type', + value: 'browser', + }, + ], + selectedMetricField: 'monitor.duration.us', + }, +]; + +describe('telemetry', function () { + it('ensures that appropriate telemetry is called when settings are applied', () => { + const trackEvent = jest.fn(); + trackTelemetryOnApply(trackEvent, mockMultipleSeries, 'kpi-over-time'); + + expect(trackEvent).toBeCalledTimes(7); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: 'exploratory_view__filters__filter_url.full', + metricType: 'count', + }); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: 'exploratory_view__filters__filter_monitor.type', + metricType: 'count', + }); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: 'exploratory_view__filters__report_type_kpi-over-time__data_type_ux__filter_url.full', + metricType: 'count', + }); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: + 'exploratory_view__filters__report_type_kpi-over-time__data_type_synthetics__filter_monitor.type', + metricType: 'count', + }); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: + 'exploratory_view__report_type_kpi-over-time__data_type_synthetics__metric_type_monitor.duration.us', + metricType: 'count', + }); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: + 'exploratory_view__report_type_kpi-over-time__data_type_ux__metric_type_transaction.duration.us', + metricType: 'count', + }); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: 'exploratory_view_apply_changes', + metricType: 'count', + }); + }); + + it('does not call track event for report type/data type/metric type config unless all values are truthy', () => { + const trackEvent = jest.fn(); + const series = { + ...mockMultipleSeries[1], + filters: undefined, + selectedMetricField: undefined, + }; + + trackTelemetryOnApply(trackEvent, [series], 'kpi-over-time'); + + expect(trackEvent).toBeCalledTimes(1); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: 'exploratory_view_apply_changes', + metricType: 'count', + }); + }); + + it.each([ + [1635784025000, '5-10'], + [1635784030000, '10-20'], + [1635784040000, '20-30'], + [1635784050000, '30-60'], + [1635784080000, '60+'], + ])('ensures that appropriate telemetry is called when chart is loaded', (endTime, range) => { + const trackEvent = jest.fn(); + trackTelemetryOnLoad(trackEvent, 1635784020000, endTime); + + expect(trackEvent).toBeCalledTimes(1); + expect(trackEvent).toBeCalledWith({ + app: 'observability-overview', + metric: `exploratory_view__chart_loading_in_seconds_${range}`, + metricType: 'count', + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/utils/telemetry.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/utils/telemetry.ts new file mode 100644 index 0000000000000..76d99824c26f0 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/utils/telemetry.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TrackEvent, METRIC_TYPE } from '../../../../hooks/use_track_metric'; +import type { SeriesUrl } from '../types'; + +export const trackTelemetryOnApply = ( + trackEvent: TrackEvent, + allSeries: SeriesUrl[], + reportType: string +) => { + trackFilters(trackEvent, allSeries, reportType); + trackDataType(trackEvent, allSeries, reportType); + trackApplyChanges(trackEvent); +}; + +export const trackTelemetryOnLoad = (trackEvent: TrackEvent, start: number, end: number) => { + trackChartLoadingTime(trackEvent, start, end); +}; + +const getAppliedFilters = (allSeries: SeriesUrl[]) => { + const filtersByDataType = new Map(); + allSeries.forEach((series) => { + const seriesFilters = filtersByDataType.get(series.dataType); + const filterFields = (series.filters || []).map((filter) => filter.field); + + if (seriesFilters) { + seriesFilters.push(...filterFields); + } else { + filtersByDataType.set(series.dataType, [...filterFields]); + } + }); + return filtersByDataType; +}; + +const trackFilters = (trackEvent: TrackEvent, allSeries: SeriesUrl[], reportType: string) => { + const filtersByDataType = getAppliedFilters(allSeries); + [...filtersByDataType.keys()].forEach((dataType) => { + const filtersForDataType = filtersByDataType.get(dataType); + + (filtersForDataType || []).forEach((filter) => { + trackEvent({ + app: 'observability-overview', + metricType: METRIC_TYPE.COUNT, + metric: `exploratory_view__filters__filter_${filter}`, + }); + trackEvent({ + app: 'observability-overview', + metricType: METRIC_TYPE.COUNT, + metric: `exploratory_view__filters__report_type_${reportType}__data_type_${dataType}__filter_${filter}`, + }); + }); + }); +}; + +const trackApplyChanges = (trackEvent: TrackEvent) => { + trackEvent({ + app: 'observability-overview', + metricType: METRIC_TYPE.COUNT, + metric: 'exploratory_view_apply_changes', + }); +}; + +const trackDataType = (trackEvent: TrackEvent, allSeries: SeriesUrl[], reportType: string) => { + const metrics = allSeries.map((series) => ({ + dataType: series.dataType, + metricType: series.selectedMetricField, + })); + + metrics.forEach(({ dataType, metricType }) => { + if (reportType && dataType && metricType) { + trackEvent({ + app: 'observability-overview', + metricType: METRIC_TYPE.COUNT, + metric: `exploratory_view__report_type_${reportType}__data_type_${dataType}__metric_type_${metricType}`, + }); + } + }); +}; + +export const trackChartLoadingTime = (trackEvent: TrackEvent, start: number, end: number) => { + const secondsLoading = (end - start) / 1000; + const rangeStr = toRangeStr(secondsLoading); + + if (rangeStr) { + trackChartLoadingMetric(trackEvent, rangeStr); + } +}; + +function toRangeStr(n: number) { + if (n < 0 || isNaN(n)) return null; + if (n >= 60) return '60+'; + else if (n >= 30) return '30-60'; + else if (n >= 20) return '20-30'; + else if (n >= 10) return '10-20'; + else if (n >= 5) return '5-10'; + return '0-5'; +} + +const trackChartLoadingMetric = (trackEvent: TrackEvent, range: string) => { + trackEvent({ + app: 'observability-overview', + metricType: METRIC_TYPE.COUNT, + metric: `exploratory_view__chart_loading_in_seconds_${range}`, + }); +}; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/utils/utils.ts b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/utils/utils.ts new file mode 100644 index 0000000000000..0edf6ff0c19e4 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/utils/utils.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { uniq } from 'lodash'; +import { ApmIndicesConfig } from '../../../../../common/typings'; + +export function getApmDataViewTitle(apmIndicesConfig?: ApmIndicesConfig) { + if (!apmIndicesConfig) { + return; + } + return uniq([apmIndicesConfig.transaction, apmIndicesConfig.metric]).join(','); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/views/add_series_button.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/views/add_series_button.test.tsx new file mode 100644 index 0000000000000..978296a295efc --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/views/add_series_button.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor, fireEvent } from '@testing-library/dom'; +import { render } from '../rtl_helpers'; +import { AddSeriesButton } from './add_series_button'; +import { DEFAULT_TIME, ReportTypes } from '../configurations/constants'; +import * as hooks from '../hooks/use_series_storage'; + +const setSeries = jest.fn(); + +describe('AddSeriesButton', () => { + beforeEach(() => { + jest.spyOn(hooks, 'useSeriesStorage').mockReturnValue({ + ...jest.requireActual('../hooks/use_series_storage'), + allSeries: [], + setSeries, + reportType: ReportTypes.KPI, + }); + setSeries.mockClear(); + }); + + it('renders AddSeriesButton', async () => { + render(); + + expect(screen.getByText(/Add series/i)).toBeInTheDocument(); + }); + + it('calls setSeries when AddSeries Button is clicked', async () => { + const { rerender } = render(); + let addSeriesButton = screen.getByText(/Add series/i); + + fireEvent.click(addSeriesButton); + + await waitFor(() => { + expect(setSeries).toBeCalledTimes(1); + expect(setSeries).toBeCalledWith(0, { name: 'new-series-1', time: DEFAULT_TIME }); + }); + + jest.clearAllMocks(); + jest.spyOn(hooks, 'useSeriesStorage').mockReturnValue({ + ...jest.requireActual('../hooks/use_series_storage'), + allSeries: new Array(1), + setSeries, + reportType: ReportTypes.KPI, + }); + + rerender(); + + addSeriesButton = screen.getByText(/Add series/i); + + fireEvent.click(addSeriesButton); + + await waitFor(() => { + expect(setSeries).toBeCalledTimes(1); + expect(setSeries).toBeCalledWith(1, { name: 'new-series-2', time: DEFAULT_TIME }); + }); + }); + + it.each([ReportTypes.DEVICE_DISTRIBUTION, ReportTypes.CORE_WEB_VITAL])( + 'does not allow adding more than 1 series for core web vitals or device distribution', + async (reportType) => { + jest.clearAllMocks(); + jest.spyOn(hooks, 'useSeriesStorage').mockReturnValue({ + ...jest.requireActual('../hooks/use_series_storage'), + allSeries: new Array(1), // mock array of length 1 + setSeries, + reportType, + }); + + render(); + const addSeriesButton = screen.getByText(/Add series/i); + expect(addSeriesButton.closest('button')).toBeDisabled(); + + fireEvent.click(addSeriesButton); + + await waitFor(() => { + expect(setSeries).toBeCalledTimes(0); + }); + } + ); + + it('does not allow adding a series when the report type is undefined', async () => { + jest.clearAllMocks(); + jest.spyOn(hooks, 'useSeriesStorage').mockReturnValue({ + ...jest.requireActual('../hooks/use_series_storage'), + allSeries: [], + setSeries, + }); + + render(); + const addSeriesButton = screen.getByText(/Add series/i); + expect(addSeriesButton.closest('button')).toBeDisabled(); + + fireEvent.click(addSeriesButton); + + await waitFor(() => { + expect(setSeries).toBeCalledTimes(0); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/views/add_series_button.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/views/add_series_button.tsx new file mode 100644 index 0000000000000..c6856b2d30770 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/views/add_series_button.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState, useRef } from 'react'; + +import { EuiToolTip, EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SeriesUrl, BuilderItem } from '../types'; +import { getSeriesToEdit } from '../series_editor/series_editor'; +import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage'; +import { useAppDataViewContext } from '../hooks/use_app_data_view'; +import { DEFAULT_TIME, ReportTypes } from '../configurations/constants'; +import { useExploratoryView } from '../contexts/exploratory_view_config'; + +export function AddSeriesButton() { + const [editorItems, setEditorItems] = useState([]); + const addSeriesButtonRef = useRef(null); + const { getSeries, allSeries, setSeries, reportType } = useSeriesStorage(); + + const { loading, dataViews } = useAppDataViewContext(); + + const { reportConfigMap } = useExploratoryView(); + + useEffect(() => { + setEditorItems(getSeriesToEdit({ allSeries, dataViews, reportType, reportConfigMap })); + }, [allSeries, getSeries, dataViews, loading, reportConfigMap, reportType]); + + const addSeries = () => { + const prevSeries = allSeries?.[0]; + const name = `${NEW_SERIES_KEY}-${editorItems.length + 1}`; + const nextSeries = { name } as SeriesUrl; + if (addSeriesButtonRef?.current) { + addSeriesButtonRef.current.blur(); + } + + const nextSeriesId = allSeries.length; + + if (reportType === 'data-distribution') { + setSeries(nextSeriesId, { + ...nextSeries, + time: prevSeries?.time || DEFAULT_TIME, + } as SeriesUrl); + } else { + setSeries( + nextSeriesId, + prevSeries ? nextSeries : ({ ...nextSeries, time: DEFAULT_TIME } as SeriesUrl) + ); + } + }; + + const isAddDisabled = + !reportType || + ((reportType === ReportTypes.CORE_WEB_VITAL || + reportType === ReportTypes.DEVICE_DISTRIBUTION || + reportType === ReportTypes.SINGLE_METRIC) && + allSeries.length > 0); + + return ( + + addSeries()} + isDisabled={isAddDisabled} + iconType="plusInCircle" + size="s" + buttonRef={addSeriesButtonRef} + > + {i18n.translate('xpack.exploratoryView.expView.seriesBuilder.addSeries', { + defaultMessage: 'Add series', + })} + + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/views/series_views.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/views/series_views.tsx new file mode 100644 index 0000000000000..00fbc8c0e522f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/views/series_views.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { RefObject } from 'react'; + +import { SeriesEditor } from '../series_editor/series_editor'; +import { AddSeriesButton } from './add_series_button'; +import { PanelId } from '../exploratory_view'; + +export function SeriesViews({ + seriesBuilderRef, +}: { + seriesBuilderRef: RefObject; + onSeriesPanelCollapse: (panel: PanelId) => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/views/view_actions.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/views/view_actions.test.tsx new file mode 100644 index 0000000000000..df709c94abcde --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/views/view_actions.test.tsx @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor, fireEvent } from '@testing-library/dom'; +import { render } from '../rtl_helpers'; +import * as hooks from '../hooks/use_series_storage'; +import { ViewActions } from './view_actions'; +import { AllSeries, reportTypeKey } from '../hooks/use_series_storage'; +import { ReportTypes } from '../../../..'; + +describe('ViewActions', () => { + const applyChanges = jest.fn(); + + const mockSeriesStorage = (allSeries: AllSeries, urlAllSeries: AllSeries) => { + jest.clearAllMocks(); + jest.spyOn(hooks, 'useSeriesStorage').mockReturnValue({ + ...jest.requireActual('../hooks/use_series_storage'), + allSeries, + applyChanges, + storage: { + get: (key: string) => { + if (key === reportTypeKey) { + return ReportTypes.KPI; + } + return urlAllSeries; + }, + } as any, + reportType: ReportTypes.KPI, + }); + }; + + const assertApplyIsEnabled = async () => { + render(); + + const applyBtn = screen.getByText(/Apply changes/i); + + const btnComponent = screen.getByTestId('seriesChangesApplyButton'); + + expect(btnComponent.classList).not.toContain('euiButton-isDisabled'); + + fireEvent.click(applyBtn); + + await waitFor(() => { + expect(applyChanges).toBeCalledTimes(1); + }); + }; + + it('renders ViewActions', async () => { + mockSeriesStorage([], []); + render(); + + expect(screen.getByText(/Apply changes/i)).toBeInTheDocument(); + }); + + it('apply button is disabled when no changes', async () => { + mockSeriesStorage([], []); + + render(); + const applyBtn = screen.getByText(/Apply changes/i); + + const btnComponent = screen.getByTestId('seriesChangesApplyButton'); + + expect(btnComponent.classList[1]).toContain('disabled'); + + fireEvent.click(applyBtn); + + await waitFor(() => { + expect(applyChanges).toBeCalledTimes(0); + }); + }); + + it('should call apply changes when series length is different', async function () { + mockSeriesStorage([], [{ name: 'testSeries' } as any]); + + await assertApplyIsEnabled(); + }); + + it('should call apply changes when series content is different', async function () { + mockSeriesStorage([{ name: 'testSeriesChange' } as any], [{ name: 'testSeries' } as any]); + + await assertApplyIsEnabled(); + }); + + it('should call apply changes when series content is different as in undefined', async function () { + mockSeriesStorage( + [{ name: undefined } as any], + [{ name: 'testSeries', operationType: undefined } as any] + ); + + await assertApplyIsEnabled(); + }); + it('apply button is disabled when no filter changes but different orders', async () => { + const allSeries: AllSeries = [ + { + seriesType: 'area', + breakdown: 'monitor.type', + filters: [ + { + values: ['spa-heartbeat', 'nyc-heartbeat', 'au-heartbeat'], + field: 'observer.geo.name', + }, + ], + time: { from: 'now-15m', to: 'now' }, + dataType: 'synthetics', + reportDefinitions: { 'monitor.name': [], 'url.full': ['ALL_VALUES'] }, + selectedMetricField: 'monitor.duration.us', + name: 'All monitors response duration', + }, + ]; + + const urlSeries: AllSeries = [ + { + seriesType: 'area', + breakdown: 'monitor.type', + filters: [ + { + field: 'observer.geo.name', + values: ['spa-heartbeat', 'nyc-heartbeat', 'au-heartbeat'], + notValues: undefined, + notWildcards: undefined, + }, + ], + time: { from: 'now-15m', to: 'now' }, + reportDefinitions: { 'monitor.name': [], 'url.full': ['ALL_VALUES'] }, + dataType: 'synthetics', + selectedMetricField: 'monitor.duration.us', + name: 'All monitors response duration', + }, + ]; + + mockSeriesStorage(allSeries, urlSeries); + + render(); + const applyBtn = screen.getByText(/Apply changes/i); + + const btnComponent = screen.getByTestId('seriesChangesApplyButton'); + + expect(btnComponent.classList[1]).toContain('disabled'); + + fireEvent.click(applyBtn); + + await waitFor(() => { + expect(applyChanges).toBeCalledTimes(0); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/views/view_actions.tsx b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/views/view_actions.tsx new file mode 100644 index 0000000000000..400cd2f8ee990 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/exploratory_view/views/view_actions.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isEqual, pickBy } from 'lodash'; +import { + allSeriesKey, + convertAllShortSeries, + reportTypeKey, + useSeriesStorage, +} from '../hooks/use_series_storage'; +import { SeriesUrl } from '../types'; + +interface Props { + onApply?: () => void; +} + +export function removeUndefinedEmptyValues(series: SeriesUrl) { + const resultSeries = removeUndefinedProps(series) as SeriesUrl; + Object.entries(resultSeries).forEach(([prop, value]) => { + if (typeof value === 'object') { + // @ts-expect-error + resultSeries[prop] = removeUndefinedEmptyValues(value); + } + }); + return resultSeries; +} + +export function removeUndefinedProps(obj: T): Partial { + return pickBy(obj, (value) => value !== undefined); +} + +export function ViewActions({ onApply }: Props) { + const { allSeries, storage, applyChanges, reportType } = useSeriesStorage(); + + const urlAllSeries = convertAllShortSeries(storage.get(allSeriesKey) ?? []); + const urlReportType = storage.get(reportTypeKey) ?? ''; + + let noChanges = allSeries.length === urlAllSeries.length && reportType === urlReportType; + + if (noChanges) { + noChanges = !allSeries.some( + (series, index) => + !isEqual( + removeUndefinedEmptyValues(series), + removeUndefinedEmptyValues(urlAllSeries[index]) + ) + ); + } + + return ( + + + applyChanges(onApply)} + isDisabled={noChanges} + fill + data-test-subj={'seriesChangesApplyButton'} + > + {i18n.translate('xpack.exploratoryView.expView.seriesBuilder.apply', { + defaultMessage: 'Apply changes', + })} + + + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/__stories__/field_value_selection.stories.tsx b/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/__stories__/field_value_selection.stories.tsx new file mode 100644 index 0000000000000..0916cc4673129 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/__stories__/field_value_selection.stories.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ComponentType, useEffect, useState } from 'react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { Observable } from 'rxjs'; +import { CoreStart } from '@kbn/core/public'; +import { text } from '@storybook/addon-knobs'; +import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; +import { FieldValueSelectionProps } from '../types'; +import { FieldValueSelection } from '../field_value_selection'; + +const values = [ + { label: 'elastic co frontend', count: 1 }, + { label: 'apm server', count: 2 }, +]; + +const KibanaReactContext = createKibanaReactContext({ + uiSettings: { get: () => {}, get$: () => new Observable() }, +} as unknown as Partial); + +export default { + title: 'app/Shared/FieldValueSuggestions', + component: FieldValueSelection, + decorators: [ + (Story: ComponentType) => ( + + + {}} + selectedValue={[]} + loading={false} + setQuery={() => {}} + /> + + + ), + ], +}; + +export function ValuesLoaded() { + return ( + {}} + selectedValue={[]} + loading={false} + setQuery={() => {}} + /> + ); +} + +export function LoadingState() { + return ( + {}} + selectedValue={[]} + loading={true} + setQuery={() => {}} + /> + ); +} + +export function EmptyState() { + return ( + {}} + selectedValue={[]} + loading={false} + setQuery={() => {}} + /> + ); +} + +export function SearchState(args: FieldValueSelectionProps) { + const name = text('Query', ''); + + const [, setQuery] = useState(''); + useEffect(() => { + setQuery(name); + }, [name]); + + return ( + {}} + selectedValue={[]} + loading={false} + setQuery={setQuery} + /> + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/field_value_combobox.tsx b/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/field_value_combobox.tsx new file mode 100644 index 0000000000000..e4aa34475fffd --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/field_value_combobox.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, { useEffect, useState } from 'react'; +import { union, isEmpty } from 'lodash'; +import { + EuiComboBox, + EuiFormControlLayout, + EuiComboBoxOptionOption, + EuiFormRow, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import { FieldValueSelectionProps } from './types'; +const formatOptions = (values?: string[], allowAllValuesSelection?: boolean) => { + const uniqueValues = Array.from( + new Set( + allowAllValuesSelection && (values ?? []).length > 0 + ? ['ALL_VALUES', ...(values ?? [])] + : values + ) + ); + + return (uniqueValues ?? []).map((label) => ({ + label, + })); +}; + +type ValueOption = EuiComboBoxOptionOption; + +export function FieldValueCombobox({ + label, + selectedValue, + loading, + values, + setQuery, + usePrependLabel = true, + compressed = true, + required = true, + singleSelection = false, + allowAllValuesSelection, + onChange: onSelectionChange, +}: FieldValueSelectionProps) { + const [options, setOptions] = useState(() => + formatOptions( + union(values?.map(({ label: lb }) => lb) ?? [], selectedValue ?? []), + allowAllValuesSelection + ) + ); + + useEffect(() => { + setOptions( + formatOptions( + union(values?.map(({ label: lb }) => lb) ?? [], selectedValue ?? []), + allowAllValuesSelection + ) + ); + }, [allowAllValuesSelection, selectedValue, values]); + + const onChange = (selectedValuesN: ValueOption[]) => { + onSelectionChange(selectedValuesN.map(({ label: lbl }) => lbl)); + }; + + const comboBox = ( + { + setQuery(searchVal); + }} + options={options} + selectedOptions={options.filter((opt) => selectedValue?.includes(opt.label))} + onChange={onChange} + isInvalid={required && isEmpty(selectedValue)} + /> + ); + + return usePrependLabel ? ( + + + {comboBox} + + + ) : ( + + {comboBox} + + ); +} + +const ComboWrapper = styled.div` + &&& { + .euiFormControlLayout { + height: auto; + .euiFormControlLayout__prepend { + margin: auto; + } + .euiComboBoxPill { + max-width: 250px; + } + .euiComboBox__inputWrap { + border-radius: 0; + } + } + } +`; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/field_value_selection.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/field_value_selection.test.tsx new file mode 100644 index 0000000000000..9839d1d5d58e9 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/field_value_selection.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount, render } from 'enzyme'; +import { FieldValueSelection } from './field_value_selection'; +import { EuiSelectableList } from '@elastic/eui'; +import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; + +const values = [ + { label: 'elastic co frontend', count: 1 }, + { label: 'apm server', count: 2 }, +]; + +describe('FieldValueSelection', () => { + it('renders a label for button', async () => { + const wrapper = render( + {}} + selectedValue={[]} + loading={false} + setQuery={() => {}} + /> + ); + + const btn = wrapper.find('[data-test-subj=fieldValueSelectionBtn]'); + + expect(btn.text()).toBe('Service name'); + }); + + it('renders a list on click', async () => { + const wrapper = mount( + + {}} + selectedValue={[]} + loading={false} + setQuery={() => {}} + /> + + ); + + const btn = wrapper.find('button[data-test-subj="fieldValueSelectionBtn"]'); + btn.simulate('click'); + + const list = wrapper.find(EuiSelectableList); + + expect((list.props() as any).visibleOptions).toMatchInlineSnapshot(` + Array [ + Object { + "append": + + 1 + + , + "label": "elastic co frontend", + }, + Object { + "append": + + 2 + + , + "label": "apm server", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/field_value_selection.tsx b/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/field_value_selection.tsx new file mode 100644 index 0000000000000..888033d8183cd --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/field_value_selection.tsx @@ -0,0 +1,300 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, { FormEvent, useEffect, useState } from 'react'; +import { + EuiText, + EuiButton, + EuiSwitch, + EuiSpacer, + EuiFilterButton, + EuiPopover, + EuiPopoverFooter, + EuiPopoverTitle, + EuiSelectable, + EuiSelectableOption, + EuiLoadingSpinner, + useEuiTheme, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import { isEqual, map } from 'lodash'; +import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { FieldValueSelectionProps, ListItem } from './types'; + +const Counter = euiStyled.div` + border-radius: ${({ theme }) => theme.eui.euiBorderRadius}; + background: ${({ theme }) => theme.eui.euiColorLightShade}; + padding: 0 ${({ theme }) => theme.eui.euiSizeXS}; +`; + +const formatOptions = ( + values?: ListItem[], + selectedValue?: string[], + excludedValues?: string[], + showCount?: boolean +): EuiSelectableOption[] => { + const uniqueValues: Record = {}; + + values?.forEach(({ label, count }) => { + uniqueValues[label] = count; + }); + + return Object.entries(uniqueValues).map(([label, count]) => ({ + label, + append: showCount ? ( + + {count} + + ) : null, + ...(selectedValue?.includes(label) ? { checked: 'on' } : {}), + ...(excludedValues?.includes(label) ? { checked: 'off' } : {}), + })); +}; + +export function FieldValueSelection({ + fullWidth, + label, + loading, + query, + setQuery, + button, + width, + forceOpen, + setForceOpen, + anchorPosition, + singleSelection, + asFilterButton, + showCount = true, + values = [], + selectedValue, + excludedValue, + allowExclusions = true, + compressed = true, + useLogicalAND, + showLogicalConditionSwitch = false, + onChange: onSelectionChange, +}: FieldValueSelectionProps) { + const { euiTheme } = useEuiTheme(); + + const [options, setOptions] = useState(() => + formatOptions(values, selectedValue, excludedValue, showCount) + ); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const [isLogicalAND, setIsLogicalAND] = useState(useLogicalAND); + + useEffect(() => { + setIsLogicalAND(useLogicalAND); + }, [useLogicalAND]); + + useEffect(() => { + setOptions(formatOptions(values, selectedValue, excludedValue, showCount)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(values), JSON.stringify(selectedValue), showCount, excludedValue]); + + const onButtonClick = () => { + setIsPopoverOpen(!isPopoverOpen); + }; + + const closePopover = () => { + setIsPopoverOpen(false); + setForceOpen?.(false); + }; + + const onChange = (optionsN: EuiSelectableOption[]) => { + setOptions(optionsN); + }; + + const onValueChange = (evt: FormEvent) => { + setQuery((evt.target as HTMLInputElement).value); + }; + + const anchorButton = ( + + {label} + + ); + + const numOfFilters = (selectedValue || []).length + (excludedValue || []).length; + + const filterButton = ( + 0} + iconType="arrowDown" + numActiveFilters={numOfFilters} + numFilters={options.length} + onClick={onButtonClick} + > + {label} + + ); + + const applyDisabled = () => { + const currSelected = (options ?? []) + .filter((opt) => opt?.checked === 'on') + .map(({ label: labelN }) => labelN); + + const currExcluded = (options ?? []) + .filter((opt) => opt?.checked === 'off') + .map(({ label: labelN }) => labelN); + + const hasFilterSelected = (selectedValue ?? []).length > 0 || (excludedValue ?? []).length > 0; + + return ( + isEqual(selectedValue ?? [], currSelected) && + isEqual(excludedValue ?? [], currExcluded) && + !(isLogicalAND !== useLogicalAND && hasFilterSelected) + ); + }; + + return ( + + + + {(list, search) => ( +
+ {search} + {list} + {loading && query && ( + + {i18n.translate('xpack.exploratoryView.fieldValueSelection.loading', { + defaultMessage: 'Loading', + })}{' '} + + + )} + + {showLogicalConditionSwitch && ( + <> + +
+ { + setIsLogicalAND(e.target.checked); + }} + /> +
+ + + )} + + { + const selectedValuesN = options.filter((opt) => opt?.checked === 'on'); + const excludedValuesN = options.filter((opt) => opt?.checked === 'off'); + + if (showLogicalConditionSwitch) { + onSelectionChange( + map(selectedValuesN, 'label'), + map(excludedValuesN, 'label'), + isLogicalAND + ); + } else { + onSelectionChange( + map(selectedValuesN, 'label'), + map(excludedValuesN, 'label') + ); + } + + setIsPopoverOpen(false); + setForceOpen?.(false); + }} + > + {i18n.translate('xpack.exploratoryView.fieldValueSelection.apply', { + defaultMessage: 'Apply', + })} + +
+
+ )} +
+
+
+ ); +} + +// eslint-disable-next-line import/no-default-export +export default FieldValueSelection; + +const Wrapper = styled.div` + &&& { + div.euiPopover__anchor { + width: 100%; + .euiButton { + width: 100%; + } + } + } +`; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/index.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/index.test.tsx new file mode 100644 index 0000000000000..249913e91b4e4 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/index.test.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FieldValueSuggestions } from '.'; +import { render, screen, fireEvent, waitForElementToBeRemoved } from '@testing-library/react'; +import * as searchHook from '../../../hooks/use_es_search'; +import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; + +jest.setTimeout(30000); + +describe('FieldValueSuggestions', () => { + jest.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(1500); + jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(1500); + + function setupSearch(data: any) { + // @ts-ignore + jest.spyOn(searchHook, 'useEsSearch').mockReturnValue({ + data: { + took: 17, + timed_out: false, + _shards: { total: 35, successful: 35, skipped: 31, failed: 0 }, + hits: { total: { value: 15299, relation: 'eq' }, hits: [] }, + aggregations: { + values: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: data, + }, + }, + }, + loading: false, + }); + } + + it('renders a list', async () => { + setupSearch([ + { key: 'US', doc_count: 14132 }, + { key: 'Pak', doc_count: 200 }, + { key: 'Japan', doc_count: 100 }, + ]); + + render( + + {}} + selectedValue={[]} + filters={[]} + asCombobox={false} + /> + + ); + + fireEvent.click(screen.getByText('Service name')); + + expect(await screen.findByPlaceholderText('Filter Service name')).toBeInTheDocument(); + expect(await screen.findByText('Apply')).toBeInTheDocument(); + expect(await screen.findByText('US')).toBeInTheDocument(); + expect(await screen.findByText('Pak')).toBeInTheDocument(); + expect(await screen.findByText('Japan')).toBeInTheDocument(); + expect(await screen.findByText('14132')).toBeInTheDocument(); + expect(await screen.findByText('200')).toBeInTheDocument(); + expect(await screen.findByText('100')).toBeInTheDocument(); + + setupSearch([{ key: 'US', doc_count: 14132 }]); + + fireEvent.input(screen.getByTestId('suggestionInputField'), { + target: { value: 'u' }, + }); + + expect(await screen.findByDisplayValue('u')).toBeInTheDocument(); + }); + + it('calls oncChange when applied', async () => { + setupSearch([ + { key: 'US', doc_count: 14132 }, + { key: 'Pak', doc_count: 200 }, + { key: 'Japan', doc_count: 100 }, + ]); + + const onChange = jest.fn(); + + const { rerender } = render( + + + + ); + + fireEvent.click(screen.getByText('Service name')); + + fireEvent.click(await screen.findByText('US')); + fireEvent.click(await screen.findByText('Apply')); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(['US'], []); + + await waitForElementToBeRemoved(() => screen.queryByText('Apply')); + + rerender( + + + + ); + + fireEvent.click(screen.getByText('Service name')); + + fireEvent.click(await screen.findByText('US')); + fireEvent.click(await screen.findByText('Pak')); + fireEvent.click(await screen.findByText('Apply')); + + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenLastCalledWith([], ['US']); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/index.tsx b/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/index.tsx new file mode 100644 index 0000000000000..ea414260a4380 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/index.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { useValuesList } from '../../../hooks/use_values_list'; +import { FieldValueSelection } from './field_value_selection'; +import { FieldValueSuggestionsProps } from './types'; +import { FieldValueCombobox } from './field_value_combobox'; + +export function FieldValueSuggestions({ + fullWidth, + sourceField, + label, + dataViewTitle, + selectedValue, + excludedValue, + filters, + button, + time, + width, + forceOpen, + setForceOpen, + anchorPosition, + singleSelection, + compressed, + asFilterButton, + usePrependLabel, + allowAllValuesSelection, + required, + allowExclusions = true, + cardinalityField, + inspector, + asCombobox = true, + keepHistory = true, + showLogicalConditionSwitch, + useLogicalAND, + onChange: onSelectionChange, +}: FieldValueSuggestionsProps) { + const [query, setQuery] = useState(''); + + const { values, loading } = useValuesList({ + dataViewTitle, + query, + sourceField, + filters, + time, + inspector, + cardinalityField, + keepHistory, + label, + }); + + const SelectionComponent = asCombobox ? FieldValueCombobox : FieldValueSelection; + + return ( + + ); +} + +// eslint-disable-next-line import/no-default-export +export default FieldValueSuggestions; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/types.ts b/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/types.ts new file mode 100644 index 0000000000000..51e89570bc241 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/field_value_suggestions/types.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PopoverAnchorPosition } from '@elastic/eui'; +import { Dispatch, SetStateAction } from 'react'; +import type { ESFilter } from '@kbn/es-types'; +import { IInspectorInfo } from '@kbn/data-plugin/common'; + +interface CommonProps { + selectedValue?: string[]; + excludedValue?: string[]; + label: string; + button?: JSX.Element; + width?: number; + singleSelection?: boolean; + forceOpen?: boolean; + setForceOpen?: (val: boolean) => void; + anchorPosition?: PopoverAnchorPosition; + fullWidth?: boolean; + compressed?: boolean; + asFilterButton?: boolean; + showCount?: boolean; + usePrependLabel?: boolean; + allowExclusions?: boolean; + allowAllValuesSelection?: boolean; + cardinalityField?: string; + required?: boolean; + keepHistory?: boolean; + showLogicalConditionSwitch?: boolean; + useLogicalAND?: boolean; + onChange: (val?: string[], excludedValue?: string[], isLogicalAND?: boolean) => void; +} + +export type FieldValueSuggestionsProps = CommonProps & { + dataViewTitle?: string; + sourceField: string; + asCombobox?: boolean; + filters: ESFilter[]; + time?: { from: string; to: string }; + inspector?: IInspectorInfo; +}; + +export type FieldValueSelectionProps = CommonProps & { + loading?: boolean; + values?: ListItem[]; + query?: string; + setQuery: Dispatch>; +}; + +export interface ListItem { + label: string; + count: number; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/filter_value_label/filter_value_label.tsx b/x-pack/plugins/exploratory_view/public/components/shared/filter_value_label/filter_value_label.tsx new file mode 100644 index 0000000000000..3b0221160720c --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/filter_value_label/filter_value_label.tsx @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { injectI18n } from '@kbn/i18n-react'; +import { Filter, buildPhrasesFilter, buildPhraseFilter } from '@kbn/es-query'; +import { FilterItem } from '@kbn/unified-search-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +export function buildFilterLabel({ + field, + value, + label, + dataView, + negate, +}: { + label: string; + value: string | Array; + negate: boolean; + field: string; + dataView: DataView; +}) { + const indexField = dataView.getFieldByName(field)!; + const areMultipleValues = Array.isArray(value) && value.length > 1; + const filter = areMultipleValues + ? buildPhrasesFilter(indexField, value, dataView) + : buildPhraseFilter(indexField, Array.isArray(value) ? value[0] : value, dataView); + + filter.meta.type = areMultipleValues ? 'phrases' : 'phrase'; + + filter.meta.value = Array.isArray(value) + ? !areMultipleValues + ? `${value[0]}` + : undefined + : value; + + filter.meta.key = label; + filter.meta.alias = null; + filter.meta.negate = negate; + filter.meta.disabled = false; + + return filter; +} + +export interface FilterValueLabelProps { + field: string; + label: string; + value: string | Array; + negate: boolean; + removeFilter: (field: string, value: string | Array, notVal: boolean) => void; + invertFilter: (val: { + field: string; + value: string | Array; + negate: boolean; + }) => void; + dataView: DataView; + allowExclusion?: boolean; +} +export function FilterValueLabel({ + label, + field, + value, + negate, + dataView, + invertFilter, + removeFilter, + allowExclusion = true, +}: FilterValueLabelProps) { + const FilterItemI18n = injectI18n(FilterItem); + + const filter = buildFilterLabel({ field, value, label, dataView, negate }); + + const { + services: { uiSettings }, + } = useKibana(); + + return dataView ? ( + { + removeFilter(field, value, false); + }} + onUpdate={(filterN: Filter) => { + if (filterN.meta.negate !== negate) { + invertFilter({ field, value, negate }); + } + }} + uiSettings={uiSettings!} + hiddenPanelOptions={[ + ...(allowExclusion ? [] : ['negateFilter' as const]), + 'pinFilter', + 'editFilter', + 'disableFilter', + ]} + /> + ) : null; +} + +// eslint-disable-next-line import/no-default-export +export default FilterValueLabel; diff --git a/x-pack/plugins/exploratory_view/public/components/shared/header_menu_portal.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/header_menu_portal.test.tsx new file mode 100644 index 0000000000000..055c974dcf6db --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/header_menu_portal.test.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import HeaderMenuPortal from './header_menu_portal'; +import { themeServiceMock } from '@kbn/core/public/mocks'; + +describe('HeaderMenuPortal', () => { + describe('when unmounted', () => { + it('calls setHeaderActionMenu with undefined', () => { + const setHeaderActionMenu = jest.fn(); + const theme$ = themeServiceMock.createTheme$(); + + const { unmount } = render( + + test + + ); + + unmount(); + + expect(setHeaderActionMenu).toHaveBeenCalledWith(undefined); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/header_menu_portal.tsx b/x-pack/plugins/exploratory_view/public/components/shared/header_menu_portal.tsx new file mode 100644 index 0000000000000..dcfe8b9ce85ba --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/header_menu_portal.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useMemo } from 'react'; +import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { HeaderMenuPortalProps } from './types'; + +// eslint-disable-next-line import/no-default-export +export default function HeaderMenuPortal({ + children, + setHeaderActionMenu, + theme$, +}: HeaderMenuPortalProps) { + const portalNode = useMemo(() => createHtmlPortalNode(), []); + + useEffect(() => { + setHeaderActionMenu((element) => { + const mount = toMountPoint(, { theme$ }); + return mount(element); + }); + + return () => { + portalNode.unmount(); + setHeaderActionMenu(undefined); + }; + }, [portalNode, setHeaderActionMenu, theme$]); + + return {children}; +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/index.tsx b/x-pack/plugins/exploratory_view/public/components/shared/index.tsx new file mode 100644 index 0000000000000..0ca2b301c4ab4 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/index.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy, Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { LoadWhenInViewProps } from './load_when_in_view/load_when_in_view'; +import type { CoreVitalProps, HeaderMenuPortalProps } from './types'; +import type { + FieldValueSuggestionsProps, + FieldValueSelectionProps, +} from './field_value_suggestions/types'; +import type { DatePickerProps } from './date_picker'; +import type { FilterValueLabelProps } from './filter_value_label/filter_value_label'; +import type { SelectableUrlListProps } from './exploratory_view/components/url_search/selectable_url_list'; +import type { ExploratoryViewPageProps } from './exploratory_view'; +export type { LazyObservabilityPageTemplateProps } from '@kbn/observability-plugin/public'; + +const CoreVitalsLazy = lazy(() => import('./core_web_vitals')); + +export function getCoreVitalsComponent(props: CoreVitalProps) { + return ( + + + + ); +} + +const HeaderMenuPortalLazy = lazy(() => import('./header_menu_portal')); + +export function HeaderMenuPortal(props: HeaderMenuPortalProps) { + return ( + }> + + + ); +} + +const FieldValueSelectionLazy = lazy( + () => import('./field_value_suggestions/field_value_selection') +); + +export function FieldValueSelection(props: FieldValueSelectionProps) { + return ( + + + + ); +} + +const FieldValueSuggestionsLazy = lazy(() => import('./field_value_suggestions')); + +export function FieldValueSuggestions(props: FieldValueSuggestionsProps) { + return ( + + + + ); +} + +const FilterValueLabelLazy = lazy(() => import('./filter_value_label/filter_value_label')); + +export function FilterValueLabel(props: FilterValueLabelProps) { + return ( + + + + ); +} + +const SelectableUrlListLazy = lazy( + () => import('./exploratory_view/components/url_search/selectable_url_list') +); + +export function SelectableUrlList(props: SelectableUrlListProps) { + return ( + + + + ); +} + +const ExploratoryViewLazy = lazy(() => import('./exploratory_view')); + +export function ExploratoryView(props: ExploratoryViewPageProps) { + return ( + + + + ); +} + +const DatePickerLazy = lazy(() => import('./date_picker')); + +export function DatePicker(props: DatePickerProps) { + return ( + }> + + + ); +} + +const LoadWhenInViewLazy = lazy(() => import('./load_when_in_view/load_when_in_view')); + +export function LoadWhenInView(props: LoadWhenInViewProps) { + return ( + }> + + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/load_when_in_view/load_when_in_view.tsx b/x-pack/plugins/exploratory_view/public/components/shared/load_when_in_view/load_when_in_view.tsx new file mode 100644 index 0000000000000..16cb8c35b34a6 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/load_when_in_view/load_when_in_view.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; +import { EuiLoadingContent } from '@elastic/eui'; +import useIntersection from 'react-use/lib/useIntersection'; + +export interface LoadWhenInViewProps { + children: JSX.Element; + initialHeight?: string | number; + placeholderTitle: string; +} + +// eslint-disable-next-line import/no-default-export +export default function LoadWhenInView({ + children, + placeholderTitle, + initialHeight = 100, +}: LoadWhenInViewProps) { + const intersectionRef = React.useRef(null); + const intersection = useIntersection(intersectionRef, { + root: null, + rootMargin: '0px', + threshold: 0.25, + }); + + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + if (intersection && intersection.intersectionRatio > 0.25) { + setIsVisible(true); + } + }, [intersection, intersection?.intersectionRatio]); + + return isVisible ? ( + children + ) : ( +
+ +
+ ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/timestamp_tooltip/index.test.tsx b/x-pack/plugins/exploratory_view/public/components/shared/timestamp_tooltip/index.test.tsx new file mode 100644 index 0000000000000..ce7fc4f53f799 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/timestamp_tooltip/index.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import moment from 'moment-timezone'; +import { TimestampTooltip } from '.'; + +function mockNow(date: string | number | Date) { + const fakeNow = new Date(date).getTime(); + return jest.spyOn(Date, 'now').mockReturnValue(fakeNow); +} + +describe('TimestampTooltip', () => { + const timestamp = 1570720000123; // Oct 10, 2019, 08:06:40.123 (UTC-7) + + beforeAll(() => { + // mock Date.now + mockNow(1570737000000); + + moment.tz.setDefault('America/Los_Angeles'); + }); + + afterAll(() => moment.tz.setDefault('')); + + it('should render component with absolute time in body and absolute time in tooltip', () => { + expect(shallow()).toMatchInlineSnapshot(` + + Oct 10, 2019, 08:06:40.123 (UTC-7) + + `); + }); + + it('should format with precision in milliseconds by default', () => { + expect( + shallow() + .find('EuiToolTip') + .prop('content') + ).toBe('Oct 10, 2019, 08:06:40.123 (UTC-7)'); + }); + + it('should format with precision in seconds', () => { + expect( + shallow() + .find('EuiToolTip') + .prop('content') + ).toBe('Oct 10, 2019, 08:06:40 (UTC-7)'); + }); + + it('should format with precision in minutes', () => { + expect( + shallow() + .find('EuiToolTip') + .prop('content') + ).toBe('Oct 10, 2019, 08:06 (UTC-7)'); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/components/shared/timestamp_tooltip/index.tsx b/x-pack/plugins/exploratory_view/public/components/shared/timestamp_tooltip/index.tsx new file mode 100644 index 0000000000000..7b82455ad5932 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/timestamp_tooltip/index.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiToolTip } from '@elastic/eui'; +import { asAbsoluteDateTime, TimeUnit } from '../../../../common/utils/formatters/datetime'; + +interface Props { + /** + * timestamp in milliseconds + */ + time: number; + timeUnit?: TimeUnit; +} + +export function TimestampTooltip({ time, timeUnit = 'milliseconds' }: Props) { + const absoluteTimeLabel = asAbsoluteDateTime(time, timeUnit); + + return ( + + <>{absoluteTimeLabel} + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/components/shared/types.ts b/x-pack/plugins/exploratory_view/public/components/shared/types.ts new file mode 100644 index 0000000000000..856a534a107f1 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/components/shared/types.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ReactNode } from 'react'; +import { AppMountParameters } from '@kbn/core/public'; +import { UXMetrics } from './core_web_vitals'; + +export interface HeaderMenuPortalProps { + children: ReactNode; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; + theme$: AppMountParameters['theme$']; +} + +export interface CoreVitalProps { + loading: boolean; + data?: UXMetrics | null; + displayServiceName?: boolean; + serviceName?: string; + totalPageViews?: number; + displayTrafficMetric?: boolean; +} diff --git a/x-pack/plugins/exploratory_view/public/config/alert_feature_ids.ts b/x-pack/plugins/exploratory_view/public/config/alert_feature_ids.ts new file mode 100644 index 0000000000000..4e9c1ee26d07c --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/config/alert_feature_ids.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AlertConsumers } from '@kbn/rule-data-utils'; +import type { ValidFeatureId } from '@kbn/rule-data-utils'; + +export const observabilityAlertFeatureIds: ValidFeatureId[] = [ + AlertConsumers.APM, + AlertConsumers.INFRASTRUCTURE, + AlertConsumers.LOGS, + AlertConsumers.UPTIME, + AlertConsumers.SLO, +]; diff --git a/x-pack/plugins/exploratory_view/public/constants.ts b/x-pack/plugins/exploratory_view/public/constants.ts new file mode 100644 index 0000000000000..4a1006682354a --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/constants.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const DEFAULT_INTERVAL = '60s'; +export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD HH:mm'; + +export const APP_ROUTE = '/app/exploratory-view' as const; diff --git a/x-pack/plugins/exploratory_view/public/context/constants.ts b/x-pack/plugins/exploratory_view/public/context/constants.ts new file mode 100644 index 0000000000000..962622b128ded --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/context/constants.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const ALERT_APP = 'alert'; +export const UX_APP = 'ux'; +export const SYNTHETICS_APP = 'synthetics'; +export const UPTIME_APP = 'uptime'; +export const APM_APP = 'apm'; +export const INFRA_LOGS_APP = 'infra_logs'; +export const INFRA_METRICS_APP = 'infra_metrics'; diff --git a/x-pack/plugins/exploratory_view/public/context/date_picker_context.tsx b/x-pack/plugins/exploratory_view/public/context/date_picker_context.tsx new file mode 100644 index 0000000000000..33d57611d1399 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/context/date_picker_context.tsx @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, useState, useMemo, useCallback } from 'react'; +import useMount from 'react-use/lib/useMount'; +import { useLocation, useHistory } from 'react-router-dom'; +import { parse } from 'query-string'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { fromQuery, ExploratoryViewPublicPluginsStart, toQuery } from '..'; +import { getAbsoluteTime } from '../utils/date'; + +export interface DatePickerContextValue { + relativeStart: string; + relativeEnd: string; + absoluteStart?: number; + absoluteEnd?: number; + refreshInterval: number; + refreshPaused: boolean; + updateTimeRange: (params: { start: string; end: string }) => void; + updateRefreshInterval: (params: { interval: number; isPaused: boolean }) => void; + lastUpdated: number; +} + +/** + * This context contains the time range (both relative and absolute) and the + * autorefresh status of the overview page date picker. + * It also updates the URL when any of the values change + */ +export const DatePickerContext = createContext({} as DatePickerContextValue); + +export function DatePickerContextProvider({ children }: { children: React.ReactElement }) { + const location = useLocation(); + const history = useHistory(); + + const updateUrl = useCallback( + ( + nextQuery: { + rangeFrom?: string; + rangeTo?: string; + refreshPaused?: boolean; + refreshInterval?: number; + }, + isFirstRender: boolean = false + ) => { + const newHistory = { + ...location, + search: fromQuery({ + ...toQuery(location.search), + ...nextQuery, + }), + }; + + if (isFirstRender) { + history.replace(newHistory); + } else { + history.push(newHistory); + } + }, + [history, location] + ); + + const [lastUpdated, setLastUpdated] = useState(Date.now()); + + const { data } = useKibana().services; + + const defaultTimeRange = data.query.timefilter.timefilter.getTimeDefaults(); + const sharedTimeRange = data.query.timefilter.timefilter.getTime(); + const defaultRefreshInterval = data.query.timefilter.timefilter.getRefreshIntervalDefaults(); + const sharedRefreshInterval = data.query.timefilter.timefilter.getRefreshInterval(); + + const { + rangeFrom = sharedTimeRange.from ?? defaultTimeRange.from, + rangeTo = sharedTimeRange.to ?? defaultTimeRange.to, + refreshInterval = sharedRefreshInterval.value || defaultRefreshInterval.value || 10000, // we want to override a default of 0 + refreshPaused = sharedRefreshInterval.pause ?? defaultRefreshInterval.pause, + } = parse(location.search, { + sort: false, + }); + + const relativeStart = rangeFrom as string; + const relativeEnd = rangeTo as string; + + const absoluteStart = useMemo( + () => getAbsoluteTime(relativeStart), + // `lastUpdated` works as a cache buster + // eslint-disable-next-line react-hooks/exhaustive-deps + [relativeStart, lastUpdated] + ); + + const absoluteEnd = useMemo( + () => getAbsoluteTime(relativeEnd, { roundUp: true }), + // `lastUpdated` works as a cache buster + // eslint-disable-next-line react-hooks/exhaustive-deps + [relativeEnd, lastUpdated] + ); + + const updateTimeRange = useCallback( + ({ start, end }: { start: string; end: string }) => { + data.query.timefilter.timefilter.setTime({ from: start, to: end }); + updateUrl({ rangeFrom: start, rangeTo: end }); + setLastUpdated(Date.now()); + }, + [data.query.timefilter.timefilter, updateUrl] + ); + + const updateRefreshInterval = useCallback( + ({ interval, isPaused }) => { + updateUrl({ refreshInterval: interval, refreshPaused: isPaused }); + data.query.timefilter.timefilter.setRefreshInterval({ value: interval, pause: isPaused }); + setLastUpdated(Date.now()); + }, + [data.query.timefilter.timefilter, updateUrl] + ); + + useMount(() => { + updateUrl({ rangeFrom: relativeStart, rangeTo: relativeEnd }, true); + }); + + return ( + + {children} + + ); +} + +function parseRefreshInterval(value: string | string[] | number | null): number { + switch (typeof value) { + case 'number': + return value; + case 'string': + return parseInt(value, 10) || 0; + default: + return 0; + } +} + +function parseRefreshPaused(value: string | string[] | boolean | null): boolean { + if (typeof value === 'boolean') { + return value; + } + + switch (value) { + case 'false': + return false; + case 'true': + default: + return true; + } +} diff --git a/x-pack/plugins/exploratory_view/public/context/has_data_context.tsx b/x-pack/plugins/exploratory_view/public/context/has_data_context.tsx new file mode 100644 index 0000000000000..275aea3f15772 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/context/has_data_context.tsx @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty, uniqueId } from 'lodash'; +import React, { createContext, useEffect, useState } from 'react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { + ALERT_APP, + APM_APP, + INFRA_LOGS_APP, + INFRA_METRICS_APP, + UPTIME_APP, + UX_APP, +} from './constants'; +import { FETCH_STATUS } from '../hooks/use_fetcher'; +import { getObservabilityAlerts } from '../services/get_observability_alerts'; +import { ObservabilityFetchDataPlugins } from '../typings/fetch_overview_data'; +import { ApmIndicesConfig } from '../../common/typings'; +import { ObservabilityAppServices } from '../application/types'; + +type DataContextApps = ObservabilityFetchDataPlugins | 'alert'; + +export type HasDataMap = Record< + DataContextApps, + { + status: FETCH_STATUS; + hasData?: boolean; + indices?: string | ApmIndicesConfig; + serviceName?: string; + } +>; + +export interface HasDataContextValue { + hasDataMap: Partial; + hasAnyData?: boolean; + isAllRequestsComplete: boolean; + onRefreshTimeRange: () => void; + forceUpdate: string; +} + +export const HasDataContext = createContext({} as HasDataContextValue); + +const apps: DataContextApps[] = [ + APM_APP, + UPTIME_APP, + INFRA_LOGS_APP, + INFRA_METRICS_APP, + UX_APP, + ALERT_APP, +]; + +export function HasDataContextProvider({ children }: { children: React.ReactNode }) { + const { http } = useKibana().services; + const [forceUpdate, setForceUpdate] = useState(''); + + const [hasDataMap, setHasDataMap] = useState({}); + + useEffect(() => { + async function fetchAlerts() { + try { + const alerts = await getObservabilityAlerts({ http }); + setHasDataMap((prevState) => ({ + ...prevState, + [ALERT_APP]: { + hasData: alerts.length > 0, + status: FETCH_STATUS.SUCCESS, + }, + })); + } catch (e) { + setHasDataMap((prevState) => ({ + ...prevState, + [ALERT_APP]: { + hasData: undefined, + status: FETCH_STATUS.FAILURE, + }, + })); + } + } + + fetchAlerts(); + }, [forceUpdate, http]); + + const isAllRequestsComplete = apps.every((app) => { + const appStatus = hasDataMap[app]?.status; + return appStatus !== undefined && appStatus !== FETCH_STATUS.LOADING; + }); + + const hasAnyData = (Object.keys(hasDataMap) as ObservabilityFetchDataPlugins[]).some((app) => { + const appHasData = hasDataMap[app]?.hasData; + return appHasData === true; + }); + + return ( + { + setForceUpdate(uniqueId()); + }, + }} + children={children} + /> + ); +} diff --git a/x-pack/plugins/exploratory_view/public/context/inspector/inspector_context.tsx b/x-pack/plugins/exploratory_view/public/context/inspector/inspector_context.tsx new file mode 100644 index 0000000000000..b5bc254dd6308 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/context/inspector/inspector_context.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, ReactNode, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { RequestAdapter } from '@kbn/inspector-plugin/common'; +import { FetcherResult } from '../../hooks/use_fetcher'; +import { InspectResponse } from '../../../typings/common'; + +export interface InspectorContextValue { + addInspectorRequest: (result: FetcherResult) => void; + inspectorAdapters: { requests: RequestAdapter }; +} + +const value: InspectorContextValue = { + addInspectorRequest: () => {}, + inspectorAdapters: { requests: new RequestAdapter() }, +}; + +export const InspectorContext = createContext(value); + +export type AddInspectorRequest = ( + result: FetcherResult<{ + mainStatisticsData?: { _inspect?: InspectResponse }; + _inspect?: InspectResponse; + }> +) => void; + +export function InspectorContextProvider({ children }: { children: ReactNode }) { + const history = useHistory(); + const { inspectorAdapters } = value; + + function addInspectorRequest( + result: FetcherResult<{ + mainStatisticsData?: { _inspect?: InspectResponse }; + _inspect?: InspectResponse; + }> + ) { + const operations = result.data?._inspect ?? result.data?.mainStatisticsData?._inspect ?? []; + + operations.forEach((operation) => { + if (operation.response) { + const { id, name } = operation; + const requestParams = { id, name }; + + const requestResponder = inspectorAdapters.requests.start( + id, + requestParams, + operation.startTime + ); + + requestResponder.json(operation.json as object); + + if (operation.stats) { + requestResponder.stats(operation.stats); + } + + requestResponder.finish(operation.status, operation.response); + } + }); + } + + useEffect(() => { + const unregisterCallback = history.listen((newLocation) => { + if (history.location.pathname !== newLocation.pathname) { + inspectorAdapters.requests.reset(); + } + }); + + return () => { + unregisterCallback(); + }; + }, [history, inspectorAdapters]); + + return ( + + {children} + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/context/inspector/use_inspector_context.tsx b/x-pack/plugins/exploratory_view/public/context/inspector/use_inspector_context.tsx new file mode 100644 index 0000000000000..a60ed6c8c72e1 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/context/inspector/use_inspector_context.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useContext } from 'react'; +import { InspectorContext } from './inspector_context'; + +export function useInspectorContext() { + return useContext(InspectorContext); +} diff --git a/x-pack/plugins/exploratory_view/public/context/plugin_context.tsx b/x-pack/plugins/exploratory_view/public/context/plugin_context.tsx new file mode 100644 index 0000000000000..5b8130b5bd648 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/context/plugin_context.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AppMountParameters } from '@kbn/core/public'; +import { createContext } from 'react'; + +export interface PluginContextValue { + appMountParameters: AppMountParameters; +} + +export const PluginContext = createContext({} as PluginContextValue); diff --git a/x-pack/plugins/exploratory_view/public/data_handler.test.ts b/x-pack/plugins/exploratory_view/public/data_handler.test.ts new file mode 100644 index 0000000000000..736fd3d968bda --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/data_handler.test.ts @@ -0,0 +1,370 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { registerDataHandler, getDataHandler } from './data_handler'; +import moment from 'moment'; +import { ApmIndicesConfig } from '../common/typings'; + +const sampleAPMIndices = { transaction: 'apm-*' } as ApmIndicesConfig; + +const params = { + absoluteTime: { + start: moment('2020-07-02T13:25:11.629Z').valueOf(), + end: moment('2020-07-09T13:25:11.629Z').valueOf(), + }, + relativeTime: { + start: 'now-15m', + end: 'now', + }, + intervalString: '10s', + bucketSize: 10, +}; + +describe('registerDataHandler', () => { + const originalConsole = global.console; + beforeAll(() => { + // mocks console to avoid polluting the test output + global.console = { error: jest.fn() } as unknown as typeof console; + }); + + afterAll(() => { + global.console = originalConsole; + }); + + describe('APM', () => { + registerDataHandler({ + appName: 'apm', + fetchData: async () => { + return { + title: 'apm', + appLink: '/apm', + stats: { + services: { + label: 'services', + type: 'number', + value: 1, + }, + transactions: { + label: 'transactions', + type: 'number', + value: 1, + }, + }, + series: { + transactions: { + label: 'transactions', + coordinates: [{ x: 1 }], + }, + }, + }; + }, + hasData: async () => ({ hasData: true, indices: sampleAPMIndices }), + }); + + it('registered data handler', () => { + const dataHandler = getDataHandler('apm'); + expect(dataHandler?.fetchData).toBeDefined(); + expect(dataHandler?.hasData).toBeDefined(); + }); + + it('returns data when fetchData is called', async () => { + const dataHandler = getDataHandler('apm'); + const response = await dataHandler?.fetchData(params); + expect(response).toEqual({ + title: 'apm', + appLink: '/apm', + stats: { + services: { + label: 'services', + type: 'number', + value: 1, + }, + transactions: { + label: 'transactions', + type: 'number', + value: 1, + }, + }, + series: { + transactions: { + label: 'transactions', + coordinates: [{ x: 1 }], + }, + }, + }); + }); + + it('returns true when hasData is called', async () => { + const dataHandler = getDataHandler('apm'); + const hasData = await dataHandler?.hasData(); + expect(hasData).toBeTruthy(); + }); + }); + describe('Logs', () => { + registerDataHandler({ + appName: 'infra_logs', + fetchData: async () => { + return { + title: 'logs', + appLink: '/logs', + stats: { + foo: { + label: 'Foo', + type: 'number', + value: 1, + }, + bar: { + label: 'bar', + type: 'number', + value: 1, + }, + }, + series: { + foo: { + label: 'Foo', + coordinates: [{ x: 1 }], + }, + bar: { + label: 'Bar', + coordinates: [{ x: 1 }], + }, + }, + }; + }, + hasData: async () => { + return { + hasData: true, + indices: 'test-index', + }; + }, + }); + + it('registered data handler', () => { + const dataHandler = getDataHandler('infra_logs'); + expect(dataHandler?.fetchData).toBeDefined(); + expect(dataHandler?.hasData).toBeDefined(); + }); + + it('returns data when fetchData is called', async () => { + const dataHandler = getDataHandler('infra_logs'); + const response = await dataHandler?.fetchData(params); + expect(response).toEqual({ + title: 'logs', + appLink: '/logs', + stats: { + foo: { + label: 'Foo', + type: 'number', + value: 1, + }, + bar: { + label: 'bar', + type: 'number', + value: 1, + }, + }, + series: { + foo: { + label: 'Foo', + coordinates: [{ x: 1 }], + }, + bar: { + label: 'Bar', + coordinates: [{ x: 1 }], + }, + }, + }); + }); + + it('returns true when hasData is called', async () => { + const dataHandler = getDataHandler('infra_logs'); + const hasData = await dataHandler?.hasData(); + expect(hasData?.hasData).toBeTruthy(); + }); + }); + describe('Uptime', () => { + registerDataHandler({ + appName: 'uptime', + fetchData: async () => { + return { + title: 'uptime', + appLink: '/uptime', + stats: { + monitors: { + label: 'Monitors', + type: 'number', + value: 1, + }, + up: { + label: 'Up', + type: 'number', + value: 1, + }, + down: { + label: 'Down', + type: 'number', + value: 1, + }, + }, + series: { + down: { + label: 'Down', + coordinates: [{ x: 1 }], + }, + up: { + label: 'Up', + coordinates: [{ x: 1 }], + }, + }, + }; + }, + hasData: async () => ({ hasData: true, indices: 'heartbeat-*,synthetics-*' }), + }); + + it('registered data handler', () => { + const dataHandler = getDataHandler('uptime'); + expect(dataHandler?.fetchData).toBeDefined(); + expect(dataHandler?.hasData).toBeDefined(); + }); + + it('returns data when fetchData is called', async () => { + const dataHandler = getDataHandler('uptime'); + const response = await dataHandler?.fetchData(params); + expect(response).toEqual({ + title: 'uptime', + appLink: '/uptime', + stats: { + monitors: { + label: 'Monitors', + type: 'number', + value: 1, + }, + up: { + label: 'Up', + type: 'number', + value: 1, + }, + down: { + label: 'Down', + type: 'number', + value: 1, + }, + }, + series: { + down: { + label: 'Down', + coordinates: [{ x: 1 }], + }, + up: { + label: 'Up', + coordinates: [{ x: 1 }], + }, + }, + }); + }); + + it('returns true when hasData is called', async () => { + const dataHandler = getDataHandler('apm'); + const hasData = await dataHandler?.hasData(); + expect(hasData).toBeTruthy(); + }); + }); + describe('Ux', () => { + registerDataHandler({ + appName: 'ux', + fetchData: async () => { + return { + title: 'User Experience', + appLink: '/ux', + coreWebVitals: { + cls: 0.01, + fid: 5, + lcp: 1464.3333333333333, + tbt: 232.92166666666665, + fcp: 1154.8, + coreVitalPages: 100, + lcpRanks: [73, 16, 11], + fidRanks: [85, 4, 11], + clsRanks: [88, 7, 5], + }, + }; + }, + hasData: async () => ({ + hasData: true, + serviceName: 'elastic-co-frontend', + indices: 'apm-*', + }), + }); + + it('registered data handler', () => { + const dataHandler = getDataHandler('ux'); + expect(dataHandler?.fetchData).toBeDefined(); + expect(dataHandler?.hasData).toBeDefined(); + }); + + it('returns data when fetchData is called', async () => { + const dataHandler = getDataHandler('ux'); + const response = await dataHandler?.fetchData(params); + expect(response).toEqual({ + title: 'User Experience', + appLink: '/ux', + coreWebVitals: { + cls: 0.01, + fid: 5, + lcp: 1464.3333333333333, + tbt: 232.92166666666665, + fcp: 1154.8, + coreVitalPages: 100, + lcpRanks: [73, 16, 11], + fidRanks: [85, 4, 11], + clsRanks: [88, 7, 5], + }, + }); + }); + + it('returns true when hasData is called', async () => { + const dataHandler = getDataHandler('ux'); + const hasData = await dataHandler?.hasData(); + expect(hasData).toBeTruthy(); + }); + }); + + describe('Metrics', () => { + const makeRequestResponse = { + title: 'metrics', + appLink: '/metrics', + sort: () => makeRequest(), + series: [], + }; + const makeRequest = async () => { + return makeRequestResponse; + }; + registerDataHandler({ + appName: 'infra_metrics', + fetchData: makeRequest, + hasData: async () => ({ hasData: true, indices: 'metrics-*' }), + }); + + it('registered data handler', () => { + const dataHandler = getDataHandler('infra_metrics'); + expect(dataHandler?.fetchData).toBeDefined(); + expect(dataHandler?.hasData).toBeDefined(); + }); + + it('returns data when fetchData is called', async () => { + const dataHandler = getDataHandler('infra_metrics'); + const response = await dataHandler?.fetchData(params); + expect(response).toEqual(makeRequestResponse); + }); + + it('returns true when hasData is called', async () => { + const dataHandler = getDataHandler('apm'); + const hasData = await dataHandler?.hasData(); + expect(hasData).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/data_handler.ts b/x-pack/plugins/exploratory_view/public/data_handler.ts new file mode 100644 index 0000000000000..c9866ba09e371 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/data_handler.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DataHandler, ObservabilityFetchDataPlugins } from './typings/fetch_overview_data'; + +const dataHandlers: Partial> = {}; + +export function registerDataHandler({ + appName, + fetchData, + hasData, +}: { appName: T } & DataHandler) { + dataHandlers[appName] = { fetchData, hasData }; +} + +export function unregisterDataHandler({ + appName, +}: { + appName: T; +}) { + delete dataHandlers[appName]; +} + +export function getDataHandler(appName: T) { + const dataHandler = dataHandlers[appName]; + if (dataHandler) { + return dataHandler as DataHandler; + } +} diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_breadcrumbs.test.tsx b/x-pack/plugins/exploratory_view/public/hooks/use_breadcrumbs.test.tsx new file mode 100644 index 0000000000000..08778d8bc3a4d --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_breadcrumbs.test.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import React, { ReactNode } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { CoreStart } from '@kbn/core/public'; +import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; +import { useBreadcrumbs } from './use_breadcrumbs'; + +const setBreadcrumbs = jest.fn(); +const setTitle = jest.fn(); +const kibanaServices = { + application: { getUrlForApp: () => {}, navigateToApp: () => {} }, + chrome: { setBreadcrumbs, docTitle: { change: setTitle } }, + uiSettings: { get: () => true }, +} as unknown as Partial; +const KibanaContext = createKibanaReactContext(kibanaServices); + +function Wrapper({ children }: { children?: ReactNode }) { + return ( + + {children} + + ); +} + +describe('useBreadcrumbs', () => { + afterEach(() => { + setBreadcrumbs.mockClear(); + setTitle.mockClear(); + }); + + describe('when setBreadcrumbs and setTitle are not defined', () => { + it('does not set breadcrumbs or the title', () => { + renderHook(() => useBreadcrumbs([]), { + wrapper: ({ children }) => ( + + + } + > + {children} + + + ), + }); + + expect(setBreadcrumbs).not.toHaveBeenCalled(); + expect(setTitle).not.toHaveBeenCalled(); + }); + }); + + describe('with an empty array', () => { + it('sets the overview breadcrumb', () => { + renderHook(() => useBreadcrumbs([]), { wrapper: Wrapper }); + + expect(setBreadcrumbs).toHaveBeenCalledWith([ + { href: '/overview', onClick: expect.any(Function), text: 'Observability' }, + ]); + }); + + it('sets the overview title', () => { + renderHook(() => useBreadcrumbs([]), { wrapper: Wrapper }); + + expect(setTitle).toHaveBeenCalledWith(['Observability']); + }); + }); + + describe('given breadcrumbs', () => { + it('sets the breadcrumbs', () => { + renderHook( + () => + useBreadcrumbs([ + { text: 'One', href: '/one' }, + { + text: 'Two', + }, + ]), + { wrapper: Wrapper } + ); + + expect(setBreadcrumbs).toHaveBeenCalledWith([ + { href: '/overview', onClick: expect.any(Function), text: 'Observability' }, + { + href: '/one', + onClick: expect.any(Function), + text: 'One', + }, + { + text: 'Two', + }, + ]); + }); + + it('sets the title', () => { + renderHook( + () => + useBreadcrumbs([ + { text: 'One', href: '/one' }, + { + text: 'Two', + }, + ]), + { wrapper: Wrapper } + ); + + expect(setTitle).toHaveBeenCalledWith(['Two', 'One', 'Observability']); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_breadcrumbs.ts b/x-pack/plugins/exploratory_view/public/hooks/use_breadcrumbs.ts new file mode 100644 index 0000000000000..71ed2bf91306f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_breadcrumbs.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { ChromeBreadcrumb } from '@kbn/core/public'; +import { MouseEvent, useEffect } from 'react'; +import { useKibana } from '../utils/kibana_react'; +import { useQueryParams } from './use_query_params'; + +function addClickHandlers( + breadcrumbs: ChromeBreadcrumb[], + navigateToHref?: (url: string) => Promise +) { + return breadcrumbs.map((bc) => ({ + ...bc, + ...(bc.href + ? { + onClick: (event: MouseEvent) => { + if (navigateToHref && bc.href) { + event.preventDefault(); + navigateToHref(bc.href); + } + }, + } + : {}), + })); +} + +function getTitleFromBreadCrumbs(breadcrumbs: ChromeBreadcrumb[]) { + return breadcrumbs.map(({ text }) => text?.toString() ?? '').reverse(); +} + +export const useBreadcrumbs = ( + extraCrumbs: ChromeBreadcrumb[], + app?: { id: string; label: string } +) => { + const params = useQueryParams(); + + const { + services: { + chrome: { docTitle, setBreadcrumbs }, + application: { getUrlForApp, navigateToUrl }, + }, + } = useKibana(); + const setTitle = docTitle.change; + const appPath = getUrlForApp(app?.id ?? 'observability-overview') ?? ''; + + useEffect(() => { + const breadcrumbs = [ + { + text: + app?.label ?? + i18n.translate('xpack.exploratoryView.breadcrumbs.observabilityLinkText', { + defaultMessage: 'Observability', + }), + href: appPath + '/overview', + }, + ...extraCrumbs, + ]; + if (setBreadcrumbs) { + setBreadcrumbs(addClickHandlers(breadcrumbs, navigateToUrl)); + } + if (setTitle) { + setTitle(getTitleFromBreadCrumbs(breadcrumbs)); + } + }, [app?.label, appPath, extraCrumbs, navigateToUrl, params, setBreadcrumbs, setTitle]); +}; diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_data_fetcher.ts b/x-pack/plugins/exploratory_view/public/hooks/use_data_fetcher.ts new file mode 100644 index 0000000000000..8e2303ac04e87 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_data_fetcher.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useMemo, useEffect } from 'react'; + +import { HttpSetup } from '@kbn/core/public'; +import { useKibana } from '../utils/kibana_react'; + +type DataFetcher = (params: T, ctrl: AbortController, http: HttpSetup) => Promise; + +export const useDataFetcher = ({ + paramsForApiCall, + initialDataState, + executeApiCall, + shouldExecuteApiCall, +}: { + paramsForApiCall: ApiCallParams; + initialDataState: AlertDataType; + executeApiCall: DataFetcher; + shouldExecuteApiCall: (params: ApiCallParams) => boolean; +}) => { + const { http } = useKibana().services; + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const [data, setData] = useState(initialDataState); + + const { fetch, cancel } = useMemo(() => { + const abortController = new AbortController(); + let isCanceled = false; + + return { + fetch: async () => { + if (shouldExecuteApiCall(paramsForApiCall)) { + setError(false); + setLoading(true); + + try { + const results = await executeApiCall(paramsForApiCall, abortController, http); + if (!isCanceled) { + setLoading(false); + setData(results); + } + } catch (e) { + setError(true); + setLoading(false); + } + } + }, + cancel: () => { + isCanceled = true; + abortController.abort(); + }, + }; + }, [executeApiCall, http, paramsForApiCall, shouldExecuteApiCall]); + + useEffect(() => { + fetch(); + + return () => { + cancel(); + }; + }, [fetch, cancel]); + + return { + data, + loading, + error, + }; +}; diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_date_picker_context.ts b/x-pack/plugins/exploratory_view/public/hooks/use_date_picker_context.ts new file mode 100644 index 0000000000000..e4d42d4e25f32 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_date_picker_context.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useContext } from 'react'; +import { DatePickerContext } from '../context/date_picker_context'; + +export function useDatePickerContext() { + return useContext(DatePickerContext); +} diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_es_search.ts b/x-pack/plugins/exploratory_view/public/hooks/use_es_search.ts new file mode 100644 index 0000000000000..63aa23c251470 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_es_search.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { ESSearchResponse } from '@kbn/es-types'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { IInspectorInfo, isCompleteResponse, isErrorResponse } from '@kbn/data-plugin/common'; +import { FETCH_STATUS, useFetcher } from './use_fetcher'; +import { useInspectorContext } from '../context/inspector/use_inspector_context'; +import { getInspectResponse } from '../../common/utils/get_inspect_response'; + +export const useEsSearch = ( + params: TParams, + fnDeps: any[], + options: { inspector?: IInspectorInfo; name: string } +) => { + const { + services: { data }, + } = useKibana<{ data: DataPublicPluginStart }>(); + + const { name } = options ?? {}; + + const { addInspectorRequest } = useInspectorContext(); + + const { data: response = {}, loading } = useFetcher(() => { + if (params.index) { + const startTime = Date.now(); + return new Promise((resolve) => { + const search$ = data.search + .search( + { + params, + }, + { + legacyHitsTotal: false, + } + ) + .subscribe({ + next: (result) => { + if (isCompleteResponse(result)) { + if (addInspectorRequest) { + addInspectorRequest({ + data: { + _inspect: [ + getInspectResponse({ + startTime, + esRequestParams: params, + esResponse: result.rawResponse, + esError: null, + esRequestStatus: 1, + operationName: name, + kibanaRequest: { + route: { + path: '/internal/bsearch', + method: 'POST', + }, + } as any, + }), + ], + }, + status: FETCH_STATUS.SUCCESS, + }); + } + // Final result + resolve(result); + search$.unsubscribe(); + } + }, + error: (err) => { + if (isErrorResponse(err)) { + console.error(err); + if (addInspectorRequest) { + addInspectorRequest({ + data: { + _inspect: [ + getInspectResponse({ + startTime, + esRequestParams: params, + esResponse: null, + esError: { originalError: err, name: err.name, message: err.message }, + esRequestStatus: 2, + operationName: name, + kibanaRequest: { + route: { + path: '/internal/bsearch', + method: 'POST', + }, + } as any, + }), + ], + }, + status: FETCH_STATUS.SUCCESS, + }); + } + } + }, + }); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...fnDeps]); + + const { rawResponse } = response as any; + + return { + data: rawResponse as ESSearchResponse, + loading: Boolean(loading), + }; +}; + +export function createEsParams(params: T): T { + return params; +} diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_fetcher.tsx b/x-pack/plugins/exploratory_view/public/hooks/use_fetcher.tsx new file mode 100644 index 0000000000000..9592f0ede76dd --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_fetcher.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 { useEffect, useState, useMemo } from 'react'; + +export enum FETCH_STATUS { + LOADING = 'loading', + SUCCESS = 'success', + FAILURE = 'failure', + PENDING = 'pending', + NOT_INITIATED = 'not_initiated', +} + +export interface FetcherResult { + data?: Data; + status: FETCH_STATUS; + error?: Error; + loading?: boolean; +} + +// fetcher functions can return undefined OR a promise. Previously we had a more simple type +// but it led to issues when using object destructuring with default values +type InferResponseType = Exclude extends Promise + ? TResponseType + : unknown; + +export function useFetcher( + fn: ({}: { signal: AbortSignal }) => TReturn, + fnDeps: any[], + options: { + preservePreviousData?: boolean; + } = {} +): FetcherResult> & { refetch: () => void } { + const { preservePreviousData = true } = options; + + const [result, setResult] = useState>>({ + data: undefined, + status: FETCH_STATUS.PENDING, + loading: true, + }); + const [counter, setCounter] = useState(0); + useEffect(() => { + let controller: AbortController = new AbortController(); + + async function doFetch() { + controller.abort(); + + controller = new AbortController(); + + const signal = controller.signal; + + const promise = fn({ signal }); + if (!promise) { + setResult((prevResult) => ({ + ...prevResult, + status: FETCH_STATUS.NOT_INITIATED, + })); + return; + } + + setResult((prevResult) => ({ + data: preservePreviousData ? prevResult.data : undefined, + status: FETCH_STATUS.LOADING, + error: undefined, + loading: true, + })); + + try { + const data = await promise; + // when http fetches are aborted, the promise will be rejected + // and this code is never reached. For async operations that are + // not cancellable, we need to check whether the signal was + // aborted before updating the result. + if (!signal.aborted) { + setResult({ + data, + loading: false, + status: FETCH_STATUS.SUCCESS, + error: undefined, + } as FetcherResult>); + } + } catch (e) { + if (!signal.aborted) { + setResult((prevResult) => ({ + data: preservePreviousData ? prevResult.data : undefined, + status: FETCH_STATUS.FAILURE, + error: e, + loading: false, + })); + } + } + } + + doFetch(); + + return () => { + controller.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [counter, ...fnDeps]); + + return useMemo(() => { + return { + ...result, + loading: result.status === FETCH_STATUS.LOADING || result.status === FETCH_STATUS.PENDING, + refetch: () => { + // this will invalidate the deps to `useEffect` and will result in a new request + setCounter((count) => count + 1); + }, + }; + }, [result]); +} diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_get_user_cases_permissions.tsx b/x-pack/plugins/exploratory_view/public/hooks/use_get_user_cases_permissions.tsx new file mode 100644 index 0000000000000..d2c47da425a01 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_get_user_cases_permissions.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useState } from 'react'; +import { CasesPermissions } from '@kbn/cases-plugin/common'; +import { useKibana } from '../utils/kibana_react'; +import { casesFeatureId } from '../../common'; + +export function useGetUserCasesPermissions() { + const [casesPermissions, setCasesPermissions] = useState({ + all: false, + read: false, + create: false, + update: false, + delete: false, + push: false, + }); + const uiCapabilities = useKibana().services.application.capabilities; + + const casesCapabilities = useKibana().services.cases.helpers.getUICapabilities( + uiCapabilities[casesFeatureId] + ); + + useEffect(() => { + setCasesPermissions({ + all: casesCapabilities.all, + create: casesCapabilities.create, + read: casesCapabilities.read, + update: casesCapabilities.update, + delete: casesCapabilities.delete, + push: casesCapabilities.push, + }); + }, [ + casesCapabilities.all, + casesCapabilities.create, + casesCapabilities.read, + casesCapabilities.update, + casesCapabilities.delete, + casesCapabilities.push, + ]); + + return casesPermissions; +} diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_has_data.ts b/x-pack/plugins/exploratory_view/public/hooks/use_has_data.ts new file mode 100644 index 0000000000000..b26b44116a395 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_has_data.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useContext } from 'react'; +import { HasDataContext } from '../context/has_data_context'; + +export function useHasData() { + return useContext(HasDataContext); +} diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_kibana_space.tsx b/x-pack/plugins/exploratory_view/public/hooks/use_kibana_space.tsx new file mode 100644 index 0000000000000..d09cea114c144 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_kibana_space.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { Space } from '@kbn/spaces-plugin/common'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useFetcher } from '@kbn/observability-plugin/public'; +import { ExploratoryViewPublicPluginsStart } from '..'; + +export const useKibanaSpace = () => { + const { services } = useKibana(); + + const { + data: space, + loading, + error, + } = useFetcher | undefined>(() => { + return services.spaces?.getActiveSpace(); + }, [services.spaces]); + + return { + space, + loading, + error, + }; +}; diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_kibana_ui_settings.tsx b/x-pack/plugins/exploratory_view/public/hooks/use_kibana_ui_settings.tsx new file mode 100644 index 0000000000000..f448e07125d8b --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_kibana_ui_settings.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { UI_SETTINGS } from '@kbn/data-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +export { UI_SETTINGS }; + +type SettingKeys = keyof typeof UI_SETTINGS; +type SettingValues = typeof UI_SETTINGS[SettingKeys]; + +export function useKibanaUISettings(key: SettingValues): T { + const { + services: { uiSettings }, + } = useKibana(); + return uiSettings!.get(key); +} diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_link_props.test.tsx b/x-pack/plugins/exploratory_view/public/hooks/use_link_props.test.tsx new file mode 100644 index 0000000000000..43650406cc929 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_link_props.test.tsx @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { createMemoryHistory } from 'history'; +import React, { PropsWithChildren } from 'react'; +import { Router } from 'react-router-dom'; +import { encode } from '@kbn/rison'; +import { coreMock } from '@kbn/core/public/mocks'; +import { CoreScopedHistory } from '@kbn/core/public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { LinkDescriptor, useLinkProps } from './use_link_props'; + +const PREFIX = '/test-basepath/s/test-space/app/'; + +const coreStartMock = coreMock.createStart(); + +coreStartMock.application.getUrlForApp.mockImplementation((app, options) => { + return `${PREFIX}${app}${options?.path}`; +}); + +const INTERNAL_APP = 'metrics'; + +const history = createMemoryHistory(); +history.push(`${PREFIX}${INTERNAL_APP}`); +const scopedHistory = new CoreScopedHistory(history, `${PREFIX}${INTERNAL_APP}`); + +function ProviderWrapper({ children }: PropsWithChildren<{}>) { + return ( + + {children}; + + ); +} + +const renderUseLinkPropsHook = (props?: Partial) => { + return renderHook(() => useLinkProps({ app: INTERNAL_APP, ...props }), { + wrapper: ProviderWrapper, + }); +}; +describe('useLinkProps hook', () => { + describe('Handles internal linking', () => { + it('Provides the correct baseline props', () => { + const { result } = renderUseLinkPropsHook({ pathname: '/' }); + expect(result.current.href).toBe('/test-basepath/s/test-space/app/metrics/'); + expect(result.current.onClick).toBeDefined(); + }); + + it('Provides the correct props with options', () => { + const { result } = renderUseLinkPropsHook({ + pathname: '/inventory', + search: { + type: 'host', + id: 'some-id', + count: '12345', + }, + }); + expect(result.current.href).toBe( + '/test-basepath/s/test-space/app/metrics/inventory?type=host&id=some-id&count=12345' + ); + expect(result.current.onClick).toBeDefined(); + }); + + it('Provides the correct props with more complex encoding', () => { + const { result } = renderUseLinkPropsHook({ + pathname: '/inventory', + search: { + type: 'host + host', + name: 'this name has spaces and ** and %', + id: 'some-id', + count: '12345', + animals: ['dog', 'cat', 'bear'], + }, + }); + expect(result.current.href).toBe( + '/test-basepath/s/test-space/app/metrics/inventory?type=host%20%2B%20host&name=this%20name%20has%20spaces%20and%20**%20and%20%25&id=some-id&count=12345&animals=dog,cat,bear' + ); + expect(result.current.onClick).toBeDefined(); + }); + + it('Provides the correct props with a consumer using Rison encoding for search', () => { + const state = { + refreshInterval: { pause: true, value: 0 }, + time: { from: 12345, to: 54321 }, + }; + const { result } = renderUseLinkPropsHook({ + pathname: '/inventory', + search: { + type: 'host + host', + state: encode(state), + }, + }); + expect(result.current.href).toBe( + '/test-basepath/s/test-space/app/metrics/inventory?type=host%20%2B%20host&state=(refreshInterval:(pause:!t,value:0),time:(from:12345,to:54321))' + ); + expect(result.current.onClick).toBeDefined(); + }); + }); + + describe('Handles external linking', () => { + it('Provides the correct baseline props', () => { + const { result } = renderUseLinkPropsHook({ + app: 'ml', + pathname: '/', + }); + expect(result.current.href).toBe('/test-basepath/s/test-space/app/ml/'); + expect(result.current.onClick).toBeDefined(); + }); + + it('Provides the correct props with pathname options', () => { + const { result } = renderUseLinkPropsHook({ + app: 'ml', + pathname: '/explorer', + search: { + type: 'host', + id: 'some-id', + count: '12345', + }, + }); + expect(result.current.href).toBe( + '/test-basepath/s/test-space/app/ml/explorer?type=host&id=some-id&count=12345' + ); + expect(result.current.onClick).toBeDefined(); + }); + + it('Provides the correct props with hash options', () => { + const { result } = renderUseLinkPropsHook({ + app: 'ml', + pathname: '/explorer', + search: { + type: 'host', + id: 'some-id', + count: '12345', + }, + }); + expect(result.current.href).toBe( + '/test-basepath/s/test-space/app/ml/explorer?type=host&id=some-id&count=12345' + ); + expect(result.current.onClick).toBeDefined(); + }); + + it('Provides the correct props with more complex encoding', () => { + const { result } = renderUseLinkPropsHook({ + app: 'ml', + pathname: '/explorer', + search: { + type: 'host + host', + name: 'this name has spaces and ** and %', + id: 'some-id', + count: '12345', + animals: ['dog', 'cat', 'bear'], + }, + }); + expect(result.current.href).toBe( + '/test-basepath/s/test-space/app/ml/explorer?type=host%20%2B%20host&name=this%20name%20has%20spaces%20and%20**%20and%20%25&id=some-id&count=12345&animals=dog,cat,bear' + ); + expect(result.current.onClick).toBeDefined(); + }); + + it('Provides the correct props with a consumer using Rison encoding for search', () => { + const state = { + refreshInterval: { pause: true, value: 0 }, + time: { from: 12345, to: 54321 }, + }; + const { result } = renderUseLinkPropsHook({ + app: 'rison-app', + hash: 'rison-route', + search: { + type: 'host + host', + state: encode(state), + }, + }); + expect(result.current.href).toBe( + '/test-basepath/s/test-space/app/rison-app#rison-route?type=host%20%2B%20host&state=(refreshInterval:(pause:!t,value:0),time:(from:12345,to:54321))' + ); + expect(result.current.onClick).toBeDefined(); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_link_props.ts b/x-pack/plugins/exploratory_view/public/hooks/use_link_props.ts new file mode 100644 index 0000000000000..55746add79ccc --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_link_props.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { stringify } from 'query-string'; +import { url as urlUtils } from '@kbn/kibana-utils-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { usePrefixPathWithBasepath } from './use_prefix_path_with_basepath'; +import { useNavigationWarningPrompt } from '../utils/navigation_warning_prompt'; + +type Search = Record; + +export interface LinkDescriptor { + app: string; + pathname?: string; + hash?: string; + search?: Search; +} + +export interface LinkProps { + href?: string; + onClick?: (e: React.MouseEvent | React.MouseEvent) => void; +} + +export interface Options { + hrefOnly?: boolean; +} + +export const useLinkProps = ( + { app, pathname, hash, search }: LinkDescriptor, + options: Options = {} +): LinkProps => { + validateParams({ app, pathname, hash, search }); + + const { prompt } = useNavigationWarningPrompt(); + const prefixer = usePrefixPathWithBasepath(); + const navigateToApp = useKibana().services.application?.navigateToApp; + const { hrefOnly } = options; + + const encodedSearch = useMemo(() => { + return search ? encodeSearch(search) : undefined; + }, [search]); + + const mergedHash = useMemo(() => { + // The URI spec defines that the query should appear before the fragment + // https://tools.ietf.org/html/rfc3986#section-3 (e.g. url.format()). However, in Kibana, apps that use + // hash based routing expect the query to be part of the hash. This will handle that. + return hash && encodedSearch ? `${hash}?${encodedSearch}` : hash; + }, [hash, encodedSearch]); + + const mergedPathname = useMemo(() => { + return pathname && encodedSearch ? `${pathname}?${encodedSearch}` : pathname; + }, [pathname, encodedSearch]); + + const href = useMemo(() => { + const builtPathname = pathname ?? ''; + const builtHash = mergedHash ? `#${mergedHash}` : ''; + const builtSearch = !hash ? (encodedSearch ? `?${encodedSearch}` : '') : ''; + + const link = `${builtPathname}${builtSearch}${builtHash}`; + + return prefixer(app, link); + }, [mergedHash, hash, encodedSearch, pathname, prefixer, app]); + + const onClick = useMemo(() => { + return (e: React.MouseEvent | React.MouseEvent) => { + if (!shouldHandleLinkEvent(e)) { + return; + } + + e.preventDefault(); + + const navigate = () => { + if (navigateToApp) { + const navigationPath = mergedHash ? `#${mergedHash}` : mergedPathname; + navigateToApp(app, { path: navigationPath ? navigationPath : undefined }); + } + }; + + // A component somewhere within the app hierarchy is requesting that we + // prompt the user before navigating. + if (prompt) { + const wantsToNavigate = window.confirm(prompt); + if (wantsToNavigate) { + navigate(); + } else { + return; + } + } else { + navigate(); + } + }; + }, [navigateToApp, mergedHash, mergedPathname, app, prompt]); + + return { + href, + // Sometimes it may not be desirable to have onClick call "navigateToApp". + // E.g. the management section of Kibana cannot be successfully deeplinked to via + // "navigateToApp". In those cases we can choose to defer to legacy behaviour. + onClick: hrefOnly ? undefined : onClick, + }; +}; + +const encodeSearch = (search: Search) => { + return stringify(urlUtils.encodeQuery(search), { sort: false, encode: false }); +}; + +const validateParams = ({ app, pathname, hash, search }: LinkDescriptor) => { + if (!app && hash) { + throw new Error( + 'The metrics and logs apps use browserHistory. Please provide a pathname rather than a hash.' + ); + } +}; + +const isModifiedEvent = (event: any) => + !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); + +export const shouldHandleLinkEvent = ( + e: React.MouseEvent | React.MouseEvent +) => !e.defaultPrevented && !isModifiedEvent(e); diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_plugin_context.tsx b/x-pack/plugins/exploratory_view/public/hooks/use_plugin_context.tsx new file mode 100644 index 0000000000000..5ea1d46ac7af3 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_plugin_context.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useContext } from 'react'; +import { PluginContext } from '../context/plugin_context'; + +export function usePluginContext() { + return useContext(PluginContext); +} diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_prefix_path_with_basepath.tsx b/x-pack/plugins/exploratory_view/public/hooks/use_prefix_path_with_basepath.tsx new file mode 100644 index 0000000000000..05fde570878da --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_prefix_path_with_basepath.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +export const usePrefixPathWithBasepath = () => { + const getUrlForApp = useKibana().services.application?.getUrlForApp; + const prefixer = useMemo(() => { + if (!getUrlForApp) { + throw new Error('Application core service is unavailable'); + } + + return (app: string, path: string) => { + return getUrlForApp(app, { path }); + }; + }, [getUrlForApp]); + return prefixer; +}; diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_query_params.ts b/x-pack/plugins/exploratory_view/public/hooks/use_query_params.ts new file mode 100644 index 0000000000000..9fcbe70b5a9ef --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_query_params.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useLocation } from 'react-router-dom'; +import { useMemo } from 'react'; +import { parse } from 'query-string'; +import { UI_SETTINGS, useKibanaUISettings } from './use_kibana_ui_settings'; +import { getAbsoluteTime } from '../utils/date'; +import { TimePickerTimeDefaults } from '../components/shared/date_picker/typings'; + +const getParsedParams = (search: string) => { + return search ? parse(search[0] === '?' ? search.slice(1) : search, { sort: false }) : {}; +}; + +export function useQueryParams() { + const { from, to } = useKibanaUISettings( + UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS + ); + + const { rangeFrom, rangeTo } = getParsedParams(useLocation().search); + + return useMemo(() => { + return { + start: (rangeFrom as string) ?? from, + end: (rangeTo as string) ?? to, + absStart: getAbsoluteTime((rangeFrom as string) ?? from)!, + absEnd: getAbsoluteTime((rangeTo as string) ?? to, { roundUp: true })!, + }; + }, [rangeFrom, rangeTo, from, to]); +} diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_quick_time_ranges.tsx b/x-pack/plugins/exploratory_view/public/hooks/use_quick_time_ranges.tsx new file mode 100644 index 0000000000000..a6b4e78d86b89 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_quick_time_ranges.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useUiSetting } from '@kbn/kibana-react-plugin/public'; +import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import { TimePickerQuickRange } from '../components/shared/exploratory_view/components/series_date_picker'; + +export function useQuickTimeRanges() { + const timePickerQuickRanges = useUiSetting( + UI_SETTINGS.TIMEPICKER_QUICK_RANGES + ); + + return timePickerQuickRanges.map(({ from, to, display }) => ({ + start: from, + end: to, + label: display, + })); +} diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_theme.tsx b/x-pack/plugins/exploratory_view/public/hooks/use_theme.tsx new file mode 100644 index 0000000000000..f0957c15ae1d6 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_theme.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useContext } from 'react'; +import { ThemeContext } from 'styled-components'; +import { EuiTheme } from '@kbn/kibana-react-plugin/common'; + +export function useTheme() { + const theme: EuiTheme = useContext(ThemeContext); + return theme; +} diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_track_metric.tsx b/x-pack/plugins/exploratory_view/public/hooks/use_track_metric.tsx new file mode 100644 index 0000000000000..7138c20ef6aa6 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_track_metric.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useMemo } from 'react'; +import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; +import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { ObservabilityApp } from '../../typings/common'; + +/** + * Note: The usage_collection plugin will take care of sending this data to the telemetry server. + * You can find the metrics that are collected by these hooks in Stack Telemetry. + * Search the index `kibana-ui-counter`. You can filter for `eventName` and/or `appName`. + */ + +interface TrackOptions { + app?: ObservabilityApp; + metricType?: UiCounterMetricType; + delay?: number; // in ms +} +type EffectDeps = unknown[]; + +interface ServiceDeps { + usageCollection: UsageCollectionSetup; // TODO: This should really be start. Looking into it. +} + +export type TrackMetricOptions = TrackOptions & { metric: string }; +export type UiTracker = ReturnType; +export type TrackEvent = (options: TrackMetricOptions) => void; + +export { METRIC_TYPE }; + +export function useUiTracker({ + app: defaultApp, +}: { app?: ObservabilityApp } = {}): TrackEvent { + const reportUiCounter = useKibana().services?.usageCollection?.reportUiCounter; + const trackEvent = useMemo(() => { + return ({ app = defaultApp, metric, metricType = METRIC_TYPE.COUNT }: TrackMetricOptions) => { + if (reportUiCounter) { + reportUiCounter(app as string, metricType, metric); + } + }; + }, [defaultApp, reportUiCounter]); + return trackEvent; +} + +export function useTrackMetric( + { app, metric, metricType = METRIC_TYPE.COUNT, delay = 0 }: TrackMetricOptions, + effectDependencies: EffectDeps = [] +) { + const reportUiCounter = useKibana().services?.usageCollection?.reportUiCounter; + + useEffect(() => { + if (!reportUiCounter) { + // eslint-disable-next-line no-console + console.log( + 'usageCollection.reportUiCounter is unavailable. Ensure this is setup via .' + ); + } else { + let decoratedMetric = metric; + if (delay > 0) { + decoratedMetric += `__delayed_${delay}ms`; + } + const id = setTimeout( + () => reportUiCounter(app as string, metricType, decoratedMetric), + Math.max(delay, 0) + ); + return () => clearTimeout(id); + } + // the dependencies are managed externally + // eslint-disable-next-line react-hooks/exhaustive-deps + }, effectDependencies); +} + +/** + * useTrackPageview is a convenience wrapper for tracking a pageview + * Its metrics will be found at: + * stack_stats.kibana.plugins.ui_metric.{app}.pageview__{path}(__delayed_{n}ms)? + */ +type TrackPageviewProps = TrackOptions & { path: string }; + +export function useTrackPageview( + { path, ...rest }: TrackPageviewProps, + effectDependencies: EffectDeps = [] +) { + useTrackMetric({ ...rest, metric: `pageview__${path}` }, effectDependencies); +} diff --git a/x-pack/plugins/exploratory_view/public/hooks/use_values_list.ts b/x-pack/plugins/exploratory_view/public/hooks/use_values_list.ts new file mode 100644 index 0000000000000..0b980021ec95a --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/hooks/use_values_list.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { capitalize, uniqBy } from 'lodash'; +import { useEffect, useState } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; +import type { ESFilter } from '@kbn/es-types'; +import { IInspectorInfo } from '@kbn/data-plugin/common'; +import { createEsParams, useEsSearch } from './use_es_search'; +import { TRANSACTION_URL } from '../components/shared/exploratory_view/configurations/constants/elasticsearch_fieldnames'; + +export interface Props { + sourceField: string; + label: string; + query?: string; + dataViewTitle?: string; + filters?: ESFilter[]; + time?: { from: string; to: string }; + keepHistory?: boolean; + cardinalityField?: string; + inspector?: IInspectorInfo; +} + +export interface ListItem { + label: string; + count: number; +} + +const uniqueValues = (values: ListItem[], prevValues: ListItem[]) => { + return uniqBy([...values, ...prevValues], 'label'); +}; + +const getIncludeClause = (sourceField: string, query?: string) => { + if (!query) { + return ''; + } + + let includeClause = ''; + + if (sourceField === TRANSACTION_URL) { + // for the url we also match leading text + includeClause = `*.${query.toLowerCase()}.*`; + } else { + if (query[0].toLowerCase() === query[0]) { + // if first letter is lowercase we also add the capitalize option + includeClause = `(${query}|${capitalize(query)}).*`; + } else { + // otherwise we add lowercase option prefix + includeClause = `(${query}|${query.toLowerCase()}).*`; + } + } + + return includeClause; +}; + +export const useValuesList = ({ + sourceField, + dataViewTitle, + query = '', + filters, + time, + label, + keepHistory, + cardinalityField, +}: Props): { values: ListItem[]; loading?: boolean } => { + const [debouncedQuery, setDebounceQuery] = useState(query); + const [values, setValues] = useState([]); + + const { from, to } = time ?? {}; + + useDebounce( + () => { + setDebounceQuery(query); + }, + 350, + [query] + ); + + useEffect(() => { + if (!query) { + // in case query is cleared, we don't wait for debounce + setDebounceQuery(query); + } + }, [query]); + + const includeClause = getIncludeClause(sourceField, query); + + const { data, loading } = useEsSearch( + createEsParams({ + index: dataViewTitle!, + body: { + query: { + bool: { + filter: [ + ...(filters ?? []), + ...(from && to + ? [ + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ] + : []), + ], + }, + }, + size: 0, + aggs: { + values: { + terms: { + field: sourceField, + size: 50, + ...(query ? { include: includeClause } : {}), + }, + ...(cardinalityField + ? { + aggs: { + count: { + cardinality: { + field: cardinalityField, + }, + }, + }, + } + : {}), + }, + }, + }, + }), + [debouncedQuery, from, to, JSON.stringify(filters), dataViewTitle, sourceField], + { name: `get${label.replace(/\s/g, '')}ValuesList` } + ); + + useEffect(() => { + const valueBuckets = data?.aggregations?.values.buckets; + const newValues = + valueBuckets?.map(({ key: value, doc_count: count, count: aggsCount }) => { + if (aggsCount) { + return { + count: aggsCount.value, + label: String(value), + }; + } + return { + count, + label: String(value), + }; + }) ?? []; + + if (keepHistory) { + setValues((prevState) => { + return uniqueValues(newValues, prevState); + }); + } else { + setValues(newValues); + } + }, [data, keepHistory, loading, query]); + + return { values, loading }; +}; diff --git a/x-pack/plugins/exploratory_view/public/index.ts b/x-pack/plugins/exploratory_view/public/index.ts new file mode 100644 index 0000000000000..0e7dc80a61b92 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/index.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// TODO: https://github.com/elastic/kibana/issues/110905 +/* eslint-disable @kbn/eslint/no_export_all */ + +import { PluginInitializer, PluginInitializerContext } from '@kbn/core/public'; +import { + Plugin, + ExploratoryViewPublicPluginsStart, + ExploratoryViewPublicPluginsSetup, + ExploratoryViewPublicStart, + ExploratoryViewPublicSetup, +} from './plugin'; +export type { + ExploratoryViewPublicSetup, + ExploratoryViewPublicStart, + ExploratoryViewPublicPluginsSetup, + ExploratoryViewPublicPluginsStart, +}; +export const plugin: PluginInitializer< + ExploratoryViewPublicSetup, + ExploratoryViewPublicStart, + ExploratoryViewPublicPluginsSetup, + ExploratoryViewPublicPluginsStart +> = (initializerContext: PluginInitializerContext) => { + return new Plugin(initializerContext); +}; + +export { ALL_VALUES_SELECTED } from './components/shared/exploratory_view/configurations/constants/url_constants'; + +export * from './components/shared/action_menu'; + +export { APP_ROUTE as EXPLORATORY_VIEW_APP_URL } from './constants'; + +export type { UXMetrics } from './components/shared/core_web_vitals'; + +export { ExploratoryView } from './components/shared'; + +export * from './typings'; + +export { NavigationWarningPromptProvider, Prompt } from './utils/navigation_warning_prompt'; +export { getApmTraceUrl } from './utils/get_apm_trace_url'; +export { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/exploratory_view_url'; +export type { AllSeries } from './components/shared/exploratory_view/hooks/use_series_storage'; +export type { SeriesUrl, UrlFilter } from './components/shared/exploratory_view/types'; +export type { ExploratoryEmbeddableProps } from './components/shared/exploratory_view/embeddable/embeddable'; + +export type { AddInspectorRequest } from './context/inspector/inspector_context'; +export { InspectorContextProvider } from './context/inspector/inspector_context'; +export { useInspectorContext } from './context/inspector/use_inspector_context'; + +export type { SeriesConfig, ConfigProps } from './components/shared/exploratory_view/types'; +export { + ReportTypes, + FILTER_RECORDS, + ENVIRONMENT_ALL, + REPORT_METRIC_FIELD, + USE_BREAK_DOWN_COLUMN, + RECORDS_FIELD, + OPERATION_COLUMN, + TERMS_COLUMN, + RECORDS_PERCENTAGE_FIELD, +} from './components/shared/exploratory_view/configurations/constants'; +export { ExploratoryViewContextProvider } from './components/shared/exploratory_view/contexts/exploratory_view_config'; +export { fromQuery, toQuery } from './utils/url'; + +export type { NavigationSection } from './services/navigation_registry'; +export { convertTo } from '../common/utils/formatters/duration'; +export { formatAlertEvaluationValue } from './utils/format_alert_evaluation_value'; diff --git a/x-pack/plugins/exploratory_view/public/plugin.ts b/x-pack/plugins/exploratory_view/public/plugin.ts new file mode 100644 index 0000000000000..3f4c949f1cc71 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/plugin.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { AppNavLinkStatus } from '@kbn/core-application-browser'; +import { BehaviorSubject } from 'rxjs'; +import { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public'; +import { + AppMountParameters, + AppUpdater, + CoreSetup, + CoreStart, + DEFAULT_APP_CATEGORIES, + Plugin as PluginClass, + PluginInitializerContext, +} from '@kbn/core/public'; +import type { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { DiscoverStart } from '@kbn/discover-plugin/public'; +import type { EmbeddableStart } from '@kbn/embeddable-plugin/public'; +import type { HomePublicPluginSetup, HomePublicPluginStart } from '@kbn/home-plugin/public'; +import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; +import { CasesUiStart } from '@kbn/cases-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; +import { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; +import { SecurityPluginStart } from '@kbn/security-plugin/public'; +import { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public'; +import { SpacesPluginStart } from '@kbn/spaces-plugin/public'; +import { LicensingPluginStart } from '@kbn/licensing-plugin/public'; +import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import { + ObservabilityPublicSetup, + ObservabilityPublicStart, +} from '@kbn/observability-plugin/public'; +import { getExploratoryViewEmbeddable } from './components/shared/exploratory_view/embeddable'; +import { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/exploratory_view_url'; +import getAppDataView from './utils/observability_data_views/get_app_data_view'; +import { registerDataHandler } from './data_handler'; +import { APP_ROUTE } from './constants'; + +export interface ExploratoryViewPublicPluginsSetup { + data: DataPublicPluginSetup; + observability: ObservabilityPublicSetup; + share: SharePluginSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; + usageCollection: UsageCollectionSetup; + home?: HomePublicPluginSetup; +} + +export interface ExploratoryViewPublicPluginsStart { + cases: CasesUiStart; + charts: ChartsPluginStart; + data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; + discover: DiscoverStart; + embeddable: EmbeddableStart; + guidedOnboarding: GuidedOnboardingPluginStart; + lens: LensPublicStart; + licensing: LicensingPluginStart; + observability: ObservabilityPublicStart; + security: SecurityPluginStart; + share: SharePluginStart; + spaces?: SpacesPluginStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; + usageCollection: UsageCollectionSetup; + unifiedSearch: UnifiedSearchPublicPluginStart; + home?: HomePublicPluginStart; +} + +export type ExploratoryViewPublicSetup = ReturnType; +export type ExploratoryViewPublicStart = ReturnType; + +export class Plugin + implements + PluginClass< + ExploratoryViewPublicSetup, + ExploratoryViewPublicStart, + ExploratoryViewPublicPluginsSetup, + ExploratoryViewPublicPluginsStart + > +{ + private readonly appUpdater$ = new BehaviorSubject(() => ({})); + + constructor(private readonly initContext: PluginInitializerContext) {} + + public setup( + core: CoreSetup, + plugins: ExploratoryViewPublicPluginsSetup + ) { + const appUpdater$ = this.appUpdater$; + + core.application.register({ + appRoute: APP_ROUTE, + category: DEFAULT_APP_CATEGORIES.observability, + euiIconType: 'logoObservability', + id: 'exploratory-view', + mount: async (params: AppMountParameters) => { + const { renderApp } = await import('./application'); + const [coreStart, pluginsStart] = await core.getStartServices(); + + return renderApp({ + core: coreStart, + appMountParameters: params, + plugins: { ...pluginsStart }, + usageCollection: plugins.usageCollection, + isDev: this.initContext.env.mode.dev, + }); + }, + title: i18n.translate('xpack.exploratoryView.appTitle', { + defaultMessage: 'Exploratory View', + }), + searchable: false, + updater$: appUpdater$, + keywords: [ + 'observability', + 'monitor', + 'logs', + 'metrics', + 'apm', + 'performance', + 'trace', + 'rum', + 'user', + 'experience', + ], + }); + + return { + register: registerDataHandler, + }; + } + + public start(coreStart: CoreStart, pluginsStart: ExploratoryViewPublicPluginsStart) { + this.appUpdater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, + })); + + return { + createExploratoryViewUrl, + getAppDataView: getAppDataView(pluginsStart.dataViews), + ExploratoryViewEmbeddable: getExploratoryViewEmbeddable({ ...coreStart, ...pluginsStart }), + }; + } +} diff --git a/x-pack/plugins/exploratory_view/public/routes/index.tsx b/x-pack/plugins/exploratory_view/public/routes/index.tsx new file mode 100644 index 0000000000000..9ce0d5ec42662 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/routes/index.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 * as t from 'io-ts'; +import React from 'react'; +import { jsonRt } from './json_rt'; +import { ObservabilityExploratoryView } from '../components/shared/exploratory_view/obsv_exploratory_view'; + +export type RouteParams = DecodeParams; + +type DecodeParams = { + [key in keyof TParams]: TParams[key] extends t.Any ? t.TypeOf : never; +}; + +export interface Params { + query?: t.HasProps; + path?: t.HasProps; +} + +export const routes = { + '/': { + handler: () => { + return ; + }, + params: { + query: t.partial({ + rangeFrom: t.string, + rangeTo: t.string, + refreshPaused: jsonRt.pipe(t.boolean), + refreshInterval: jsonRt.pipe(t.number), + }), + }, + exact: true, + }, +}; diff --git a/x-pack/plugins/exploratory_view/public/routes/json_rt.ts b/x-pack/plugins/exploratory_view/public/routes/json_rt.ts new file mode 100644 index 0000000000000..0207145a17be7 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/routes/json_rt.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; + +export const jsonRt = new t.Type( + 'JSON', + t.any.is, + (input, context) => + either.chain(t.string.validate(input, context), (str) => { + try { + return t.success(JSON.parse(str)); + } catch (e) { + return t.failure(input, context); + } + }), + (a) => JSON.stringify(a) +); diff --git a/x-pack/plugins/exploratory_view/public/services/get_observability_alerts.ts b/x-pack/plugins/exploratory_view/public/services/get_observability_alerts.ts new file mode 100644 index 0000000000000..57cc6cec10c83 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/services/get_observability_alerts.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { HttpSetup } from '@kbn/core/public'; +import { Rule } from '@kbn/alerting-plugin/common'; + +const allowedConsumers = ['apm', 'uptime', 'logs', 'infrastructure', 'alerts']; + +export async function getObservabilityAlerts({ http }: { http: HttpSetup }) { + try { + const { data = [] }: { data: Rule[] } = + (await http.get('/api/alerts/_find', { + query: { + page: 1, + per_page: 20, + }, + })) || {}; + + return data.filter(({ consumer }) => allowedConsumers.includes(consumer)); + } catch (e) { + console.error('Error while fetching alerts', e); + throw e; + } +} diff --git a/x-pack/plugins/exploratory_view/public/services/navigation_registry.test.ts b/x-pack/plugins/exploratory_view/public/services/navigation_registry.test.ts new file mode 100644 index 0000000000000..7b330abc0f95d --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/services/navigation_registry.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { of, firstValueFrom } from 'rxjs'; +import { createNavigationRegistry } from './navigation_registry'; + +describe('Navigation registry', () => { + it('Allows the registration of, and access to, navigation sections', async () => { + const navigationRegistry = createNavigationRegistry(); + + navigationRegistry.registerSections( + of([ + { + label: 'Test A', + sortKey: 100, + entries: [ + { label: 'Url A', app: 'TestA', path: '/url-a' }, + { label: 'Url B', app: 'TestA', path: '/url-b' }, + ], + }, + { + label: 'Test B', + sortKey: 200, + entries: [ + { label: 'Url A', app: 'TestB', path: '/url-a' }, + { label: 'Url B', app: 'TestB', path: '/url-b' }, + ], + }, + ]) + ); + + const sections = await firstValueFrom(navigationRegistry.sections$); + + expect(sections).toEqual([ + { + label: 'Test A', + sortKey: 100, + entries: [ + { + label: 'Url A', + app: 'TestA', + path: '/url-a', + }, + { + label: 'Url B', + app: 'TestA', + path: '/url-b', + }, + ], + }, + { + label: 'Test B', + sortKey: 200, + entries: [ + { + label: 'Url A', + app: 'TestB', + path: '/url-a', + }, + { + label: 'Url B', + app: 'TestB', + path: '/url-b', + }, + ], + }, + ]); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/services/navigation_registry.ts b/x-pack/plugins/exploratory_view/public/services/navigation_registry.ts new file mode 100644 index 0000000000000..5f10a6f6c6851 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/services/navigation_registry.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { combineLatest, Observable, ReplaySubject } from 'rxjs'; +import { map, scan, shareReplay, switchMap } from 'rxjs/operators'; + +export interface NavigationSection { + // the label of the section, should be translated + label: string | undefined; + // the key to sort by in ascending order relative to other entries + sortKey: number; + // the entries to render inside the section + entries: NavigationEntry[]; + // shows beta badge besides the navigation label + isBetaFeature?: boolean; +} + +export interface NavigationEntry { + // the label of the menu entry, should be translated + label: string; + // the kibana app id + app: string; + // the path after the application prefix corresponding to this entry + path: string; + // whether to only match when the full path matches, defaults to `false` + matchFullPath?: boolean; + // whether to ignore trailing slashes, defaults to `true` + ignoreTrailingSlash?: boolean; + // handler to be called when the item is clicked + onClick?: (event: React.MouseEvent) => void; + // shows NEW badge besides the navigation label, which will automatically disappear when menu item is clicked. + isNewFeature?: boolean; + // shows technical preview lab icon if the feature is still in technical preview besides the navigation label + isTechnicalPreview?: boolean; + // shows beta badge besides the navigation label + isBetaFeature?: boolean; + // override default path matching logic to determine if nav entry is selected + matchPath?: (path: string) => boolean; +} + +export interface NavigationRegistry { + registerSections: (sections$: Observable) => void; + sections$: Observable; +} + +export const createNavigationRegistry = (): NavigationRegistry => { + const registeredSections$ = new ReplaySubject>(); + + const registerSections = (sections$: Observable) => { + registeredSections$.next(sections$); + }; + + const sections$: Observable = registeredSections$.pipe( + scan( + (accumulatedSections$, newSections) => accumulatedSections$.add(newSections), + new Set>() + ), + switchMap((registeredSections) => combineLatest([...registeredSections])), + map((registeredSections) => + registeredSections.flat().sort((first, second) => first.sortKey - second.sortKey) + ), + shareReplay(1) + ); + + return { + registerSections, + sections$, + }; +}; diff --git a/x-pack/plugins/exploratory_view/public/typings/alerts.ts b/x-pack/plugins/exploratory_view/public/typings/alerts.ts new file mode 100644 index 0000000000000..2e5dfe3ca86f2 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/typings/alerts.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 { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common'; +import { ParsedExperimentalFields } from '@kbn/rule-registry-plugin/common/parse_experimental_fields'; + +export interface TopAlert = {}> { + fields: ParsedTechnicalFields & ParsedExperimentalFields & TAdditionalMetaFields; + start: number; + lastUpdated: number; + reason: string; + link?: string; + active: boolean; +} diff --git a/x-pack/plugins/exploratory_view/public/typings/fetch_overview_data/index.ts b/x-pack/plugins/exploratory_view/public/typings/fetch_overview_data/index.ts new file mode 100644 index 0000000000000..78837ed27f800 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/typings/fetch_overview_data/index.ts @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ObservabilityApp } from '../../../typings/common'; +import type { UXMetrics } from '../../components/shared/core_web_vitals'; +import { ApmIndicesConfig } from '../../../common/typings'; + +export interface Stat { + type: 'number' | 'percent' | 'bytesPerSecond'; + value: number; +} + +export interface Coordinates { + x: number; + y?: number | null; +} + +export interface Series { + coordinates: Coordinates[]; +} + +export interface FetchDataParams { + absoluteTime: { start: number; end: number }; + relativeTime: { start: string; end: string }; + serviceName?: string; + // Bucket size in seconds (number) + bucketSize: number; + // Bucket size in seconds (string) + intervalString: string; + timeZone?: string; +} + +export interface HasDataParams { + absoluteTime: { start: number; end: number }; +} + +export interface HasDataResponse { + hasData: boolean; +} + +export interface UXHasDataResponse extends HasDataResponse { + serviceName?: string | number; + indices?: string; +} + +export interface SyntheticsHasDataResponse extends HasDataResponse { + indices: string; +} + +export interface APMHasDataResponse { + hasData: boolean; + indices: ApmIndicesConfig; +} + +export interface InfraMetricsHasDataResponse { + hasData: boolean; + indices: string; +} + +export interface InfraLogsHasDataResponse { + hasData: boolean; + indices: string; +} + +export type FetchData = ( + fetchDataParams: FetchDataParams +) => Promise; + +export type HasData = ( + params?: HasDataParams +) => Promise; + +export type ObservabilityFetchDataPlugins = Exclude< + ObservabilityApp, + 'observability-overview' | 'stack_monitoring' | 'fleet' | 'synthetics' +>; + +export interface DataHandler< + T extends ObservabilityFetchDataPlugins = ObservabilityFetchDataPlugins +> { + fetchData: FetchData; + hasData: HasData; +} + +export interface FetchDataResponse { + appLink: string; +} + +export interface LogsFetchDataResponse extends FetchDataResponse { + stats: Record; + series: Record; +} + +export type StringOrNull = string | null; +export type NumberOrNull = number | null; + +export interface MetricsFetchDataSeries { + id: string; + name: StringOrNull; + platform: StringOrNull; + provider: StringOrNull; + cpu: NumberOrNull; + iowait: NumberOrNull; + load: NumberOrNull; + uptime: NumberOrNull; + rx: NumberOrNull; + tx: NumberOrNull; + timeseries: Array<{ + timestamp: number; + cpu: NumberOrNull; + iowait: NumberOrNull; + load: NumberOrNull; + rx: NumberOrNull; + tx: NumberOrNull; + }>; +} + +export interface MetricsFetchDataResponse extends FetchDataResponse { + sort: (by: string, direction: string) => Promise; + series: MetricsFetchDataSeries[]; +} + +export interface UptimeFetchDataResponse extends FetchDataResponse { + stats: { + monitors: Stat; + up: Stat; + down: Stat; + }; + series: { + up: Series; + down: Series; + }; +} + +export interface ApmFetchDataResponse extends FetchDataResponse { + stats: { + services: Stat; + transactions: Stat; + }; + series: { + transactions: Series; + }; +} + +export interface UxFetchDataResponse extends FetchDataResponse { + coreWebVitals: UXMetrics; +} + +export interface ObservabilityFetchDataResponse { + apm: ApmFetchDataResponse; + infra_metrics: MetricsFetchDataResponse; + infra_logs: LogsFetchDataResponse; + uptime: UptimeFetchDataResponse; + ux: UxFetchDataResponse; +} + +export interface ObservabilityHasDataResponse { + apm: APMHasDataResponse; + infra_metrics: InfraMetricsHasDataResponse; + infra_logs: InfraLogsHasDataResponse; + uptime: SyntheticsHasDataResponse; + ux: UXHasDataResponse; +} diff --git a/x-pack/plugins/exploratory_view/public/typings/index.ts b/x-pack/plugins/exploratory_view/public/typings/index.ts new file mode 100644 index 0000000000000..b0cf1484d201a --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/typings/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './fetch_overview_data'; +export * from './utils'; diff --git a/x-pack/plugins/exploratory_view/public/typings/utils.ts b/x-pack/plugins/exploratory_view/public/typings/utils.ts new file mode 100644 index 0000000000000..dfe024167c55d --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/typings/utils.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Allow partial of nested object + */ +export type Subset = { + [attr in keyof K]?: K[attr] extends object ? Subset : K[attr]; +}; diff --git a/x-pack/plugins/exploratory_view/public/utils/build_es_query/__snapshots__/build_es_query.test.ts.snap b/x-pack/plugins/exploratory_view/public/utils/build_es_query/__snapshots__/build_es_query.test.ts.snap new file mode 100644 index 0000000000000..118435d5c6968 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/build_es_query/__snapshots__/build_es_query.test.ts.snap @@ -0,0 +1,169 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`buildEsQuery should generate correct es query for {"timeRange":{"from":"2022-08-30T15:23:23.721Z","to":"2022-08-30T15:38:28.171Z"},"kuery":""} 1`] = ` +Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@timestamp": Object { + "format": "strict_date_optional_time", + "gte": "2022-08-30T15:23:23.721Z", + "lte": "2022-08-30T15:38:28.171Z", + }, + }, + }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, +} +`; + +exports[`buildEsQuery should generate correct es query for {"timeRange":{"from":"2022-08-30T15:23:23.721Z","to":"2022-08-30T15:38:28.171Z"},"kuery":"kibana.alert.status: \\"active\\""} 1`] = ` +Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "kibana.alert.status": "active", + }, + }, + ], + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "strict_date_optional_time", + "gte": "2022-08-30T15:23:23.721Z", + "lte": "2022-08-30T15:38:28.171Z", + }, + }, + }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, +} +`; + +exports[`buildEsQuery should generate correct es query for {"timeRange":{"from":"2022-08-30T15:23:23.721Z","to":"2022-08-30T15:38:28.171Z"},"kuery":"kibana.alert.status: \\"recovered\\" and kibana.alert.duration.us >= 120"} 1`] = ` +Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "kibana.alert.status": "recovered", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "range": Object { + "kibana.alert.duration.us": Object { + "gte": "120", + }, + }, + }, + ], + }, + }, + ], + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "strict_date_optional_time", + "gte": "2022-08-30T15:23:23.721Z", + "lte": "2022-08-30T15:38:28.171Z", + }, + }, + }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, +} +`; + +exports[`buildEsQuery should generate correct es query for {"timeRange":{"from":"2022-08-30T15:23:23.721Z","to":"2022-08-30T15:38:28.171Z"},"kuery":"nestedField: { child: \\"something\\" }"} 1`] = ` +Object { + "bool": Object { + "filter": Array [ + Object { + "nested": Object { + "path": "nestedField", + "query": Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "nestedField.child": "something", + }, + }, + ], + }, + }, + "score_mode": "none", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "strict_date_optional_time", + "gte": "2022-08-30T15:23:23.721Z", + "lte": "2022-08-30T15:38:28.171Z", + }, + }, + }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, +} +`; + +exports[`buildEsQuery should generate correct es query for {"timeRange":{"from":"2022-08-30T15:23:23.721Z","to":"2022-08-30T15:38:28.171Z"}} 1`] = ` +Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@timestamp": Object { + "format": "strict_date_optional_time", + "gte": "2022-08-30T15:23:23.721Z", + "lte": "2022-08-30T15:38:28.171Z", + }, + }, + }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, +} +`; + diff --git a/x-pack/plugins/exploratory_view/public/utils/build_es_query/build_es_query.test.ts b/x-pack/plugins/exploratory_view/public/utils/build_es_query/build_es_query.test.ts new file mode 100644 index 0000000000000..4bbacaa7bb1ad --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/build_es_query/build_es_query.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { buildEsQuery } from './build_es_query'; + +describe('buildEsQuery', () => { + const from = '2022-08-30T15:23:23.721Z'; + const to = '2022-08-30T15:38:28.171Z'; + const defaultTimeRange = { + from, + to, + }; + const testData = [ + { + timeRange: defaultTimeRange, + kuery: '', + }, + { + timeRange: defaultTimeRange, + }, + { + timeRange: defaultTimeRange, + kuery: 'nestedField: { child: "something" }', + }, + { + timeRange: defaultTimeRange, + kuery: 'kibana.alert.status: "active"', + }, + { + timeRange: defaultTimeRange, + kuery: 'kibana.alert.status: "recovered" and kibana.alert.duration.us >= 120', + }, + ]; + + test.each(testData)('should generate correct es query for %j', ({ kuery, timeRange }) => { + expect(buildEsQuery(timeRange, kuery)).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/utils/build_es_query/build_es_query.ts b/x-pack/plugins/exploratory_view/public/utils/build_es_query/build_es_query.ts new file mode 100644 index 0000000000000..f3f195e0df9bf --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/build_es_query/build_es_query.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { buildEsQuery as kbnBuildEsQuery, TimeRange, Query } from '@kbn/es-query'; +import { TIMESTAMP } from '@kbn/rule-data-utils'; +import { getTime } from '@kbn/data-plugin/common'; + +export function buildEsQuery(timeRange: TimeRange, kuery?: string, queries: Query[] = []) { + const timeFilter = + timeRange && + getTime(undefined, timeRange, { + fieldName: TIMESTAMP, + }); + const filtersToUse = timeFilter ? [timeFilter] : []; + const kueryFilter = kuery ? [{ query: kuery, language: 'kuery' }] : []; + const queryToUse = [...kueryFilter, ...queries]; + + return kbnBuildEsQuery(undefined, queryToUse, filtersToUse); +} diff --git a/x-pack/plugins/exploratory_view/public/utils/build_es_query/index.ts b/x-pack/plugins/exploratory_view/public/utils/build_es_query/index.ts new file mode 100644 index 0000000000000..cccd37176ae5f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/build_es_query/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { buildEsQuery } from './build_es_query'; diff --git a/x-pack/plugins/exploratory_view/public/utils/cases_permissions.ts b/x-pack/plugins/exploratory_view/public/utils/cases_permissions.ts new file mode 100644 index 0000000000000..2b3ff9cfbaf54 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/cases_permissions.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const noCasesPermissions = () => ({ + all: false, + create: false, + read: false, + update: false, + delete: false, + push: false, +}); diff --git a/x-pack/plugins/exploratory_view/public/utils/date.ts b/x-pack/plugins/exploratory_view/public/utils/date.ts new file mode 100644 index 0000000000000..5839adf92618b --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/date.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import datemath from '@kbn/datemath'; + +export function getAbsoluteTime(range: string, opts: Parameters[1] = {}) { + const parsed = datemath.parse(range, opts); + if (parsed) { + return parsed.valueOf(); + } +} + +export function getAbsoluteDateRange({ + rangeFrom, + rangeTo, +}: { + rangeFrom?: string; + rangeTo?: string; +}) { + if (!rangeFrom || !rangeTo) { + return { start: undefined, end: undefined }; + } + + const absoluteStart = getAbsoluteTime(rangeFrom); + const absoluteEnd = getAbsoluteTime(rangeTo, { roundUp: true }); + + if (!absoluteStart || !absoluteEnd) { + throw new Error('Could not parse date range'); + } + + return { + start: new Date(absoluteStart).toISOString(), + end: new Date(absoluteEnd).toISOString(), + }; +} diff --git a/x-pack/plugins/exploratory_view/public/utils/datemath.test.ts b/x-pack/plugins/exploratory_view/public/utils/datemath.test.ts new file mode 100644 index 0000000000000..c5c89fc5e9619 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/datemath.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isValidDatemath } from './datemath'; + +describe('isValidDatemath()', () => { + it('Returns `false` for empty strings', () => { + expect(isValidDatemath('')).toBe(false); + }); + + it('Returns `false` for invalid strings', () => { + expect(isValidDatemath('wadus')).toBe(false); + expect(isValidDatemath('nowww-')).toBe(false); + expect(isValidDatemath('now-')).toBe(false); + expect(isValidDatemath('now-1')).toBe(false); + expect(isValidDatemath('now-1d/')).toBe(false); + }); + + it('Returns `true` for valid strings', () => { + expect(isValidDatemath('now')).toBe(true); + expect(isValidDatemath('now-1d')).toBe(true); + expect(isValidDatemath('now-1d/d')).toBe(true); + expect(isValidDatemath('2022-11-09T09:37:10.481Z')).toBe(true); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/utils/datemath.ts b/x-pack/plugins/exploratory_view/public/utils/datemath.ts new file mode 100644 index 0000000000000..3fac2cf46933e --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/datemath.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import dateMath from '@kbn/datemath'; +import { chain } from 'fp-ts/Either'; +import { pipe } from 'fp-ts/pipeable'; +import * as r from 'io-ts'; + +// Copied from x-pack/plugins/infra/public/utils/datemath.ts +export function isValidDatemath(value: string): boolean { + const parsedValue = dateMath.parse(value); + return !!(parsedValue && parsedValue.isValid()); +} + +export const datemathStringRT = new r.Type( + 'datemath', + r.string.is, + (value, context) => + pipe( + r.string.validate(value, context), + chain((stringValue) => + isValidDatemath(stringValue) ? r.success(stringValue) : r.failure(stringValue, context) + ) + ), + String +); diff --git a/x-pack/plugins/exploratory_view/public/utils/format_alert_evaluation_value.test.ts b/x-pack/plugins/exploratory_view/public/utils/format_alert_evaluation_value.test.ts new file mode 100644 index 0000000000000..ddef182541466 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/format_alert_evaluation_value.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { formatAlertEvaluationValue } from './format_alert_evaluation_value'; + +describe('formatAlertEvaluationValue', () => { + it('returns - when there is no evaluationValue passed', () => { + expect(formatAlertEvaluationValue('apm.transaction_error_rate', undefined)).toBe('-'); + }); + it('returns the evaluation value when ruleTypeId in unknown aka unformatted', () => { + expect(formatAlertEvaluationValue('unknown.rule.type', 2000)).toBe(2000); + }); + it('returns the evaluation value formatted as percent when the alert rule type is "apm.transaction_error_rate" ', () => { + expect(formatAlertEvaluationValue('apm.transaction_error_rate', 20)).toBe('20%'); + }); + it('returns the evaluation value formatted as duration in ms when the alert rule type is "apm.transaction_duration" ', () => { + expect(formatAlertEvaluationValue('apm.transaction_duration', 140000)).toBe('140 ms'); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/utils/format_alert_evaluation_value.ts b/x-pack/plugins/exploratory_view/public/utils/format_alert_evaluation_value.ts new file mode 100644 index 0000000000000..92629934f2cea --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/format_alert_evaluation_value.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { asMillisecondDuration, asPercent } from '../../common/utils/formatters'; +import { + ALERT_EVALUATION_UNIT_TYPE, + getAlertEvaluationUnitTypeByRuleTypeId, +} from './get_alert_evaluation_unit_type_by_rule_type_id'; + +export const formatAlertEvaluationValue = (ruleTypeId?: string, evaluationValue?: number) => { + if (!evaluationValue || !ruleTypeId) return '-'; + const unitType = getAlertEvaluationUnitTypeByRuleTypeId(ruleTypeId); + switch (unitType) { + case ALERT_EVALUATION_UNIT_TYPE.DURATION: + return asMillisecondDuration(evaluationValue); + case ALERT_EVALUATION_UNIT_TYPE.PERCENT: + return asPercent(evaluationValue, 100); + default: + return evaluationValue; + } +}; diff --git a/x-pack/plugins/exploratory_view/public/utils/format_stat_value.test.ts b/x-pack/plugins/exploratory_view/public/utils/format_stat_value.test.ts new file mode 100644 index 0000000000000..8a7ef2ed5657f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/format_stat_value.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { formatStatValue } from './format_stat_value'; +import { Stat } from '../typings'; + +describe('formatStatValue', () => { + it('formats value as number', () => { + const stat = { + type: 'number', + label: 'numeral stat', + value: 1000, + } as Stat; + expect(formatStatValue(stat)).toEqual('1k'); + }); + it('formats value as bytes', () => { + expect( + formatStatValue({ + type: 'bytesPerSecond', + label: 'bytes stat', + value: 1, + } as Stat) + ).toEqual('1.0B/s'); + expect( + formatStatValue({ + type: 'bytesPerSecond', + label: 'bytes stat', + value: 1048576, + } as Stat) + ).toEqual('1.0MB/s'); + expect( + formatStatValue({ + type: 'bytesPerSecond', + label: 'bytes stat', + value: 1073741824, + } as Stat) + ).toEqual('1.0GB/s'); + }); + it('formats value as percent', () => { + const stat = { + type: 'percent', + label: 'percent stat', + value: 0.841, + } as Stat; + expect(formatStatValue(stat)).toEqual('84.1%'); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/utils/format_stat_value.ts b/x-pack/plugins/exploratory_view/public/utils/format_stat_value.ts new file mode 100644 index 0000000000000..6935ac5b9893e --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/format_stat_value.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import numeral from '@elastic/numeral'; +import { Stat } from '../typings'; + +export function formatStatValue(stat: Stat) { + const { value, type } = stat; + switch (type) { + case 'bytesPerSecond': + return `${numeral(value).format('0.0b')}/s`; + case 'number': + return numeral(value).format('0a'); + case 'percent': + return numeral(value).format('0.0%'); + } +} diff --git a/x-pack/plugins/exploratory_view/public/utils/get_alert_evaluation_unit_type_by_rule_type_id.ts b/x-pack/plugins/exploratory_view/public/utils/get_alert_evaluation_unit_type_by_rule_type_id.ts new file mode 100644 index 0000000000000..d770415552bb7 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/get_alert_evaluation_unit_type_by_rule_type_id.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const ALERT_EVALUATION_UNIT_TYPE = { + DURATION: 'DURATION', + PERCENT: 'PERCENT', + NUMBER: 'NUMBER', +} as const; + +type ObjectValues = T[keyof T]; +type AlertEvaluationUnitType = ObjectValues; + +export const getAlertEvaluationUnitTypeByRuleTypeId = ( + ruleTypeId: string +): AlertEvaluationUnitType => { + switch (ruleTypeId) { + case 'apm.transaction_duration': + return ALERT_EVALUATION_UNIT_TYPE.DURATION; + case 'apm.transaction_error_rate': + return ALERT_EVALUATION_UNIT_TYPE.PERCENT; + default: + return ALERT_EVALUATION_UNIT_TYPE.NUMBER; + } +}; diff --git a/x-pack/plugins/exploratory_view/public/utils/get_apm_trace_url.test.ts b/x-pack/plugins/exploratory_view/public/utils/get_apm_trace_url.test.ts new file mode 100644 index 0000000000000..4ba4ad7f1ac64 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/get_apm_trace_url.test.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getApmTraceUrl } from './get_apm_trace_url'; + +describe('getApmTraceUrl', () => { + it('returns a trace url', () => { + expect(getApmTraceUrl({ traceId: 'foo', rangeFrom: '123', rangeTo: '456' })).toEqual( + '/link-to/trace/foo?rangeFrom=123&rangeTo=456' + ); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/utils/get_apm_trace_url.ts b/x-pack/plugins/exploratory_view/public/utils/get_apm_trace_url.ts new file mode 100644 index 0000000000000..315ea2c27837e --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/get_apm_trace_url.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function getApmTraceUrl({ + traceId, + rangeFrom, + rangeTo, +}: { + traceId: string; + rangeFrom: string; + rangeTo: string; +}) { + return `/link-to/trace/${traceId}?` + new URLSearchParams({ rangeFrom, rangeTo }).toString(); +} diff --git a/x-pack/plugins/exploratory_view/public/utils/get_bucket_size/calculate_auto.js b/x-pack/plugins/exploratory_view/public/utils/get_bucket_size/calculate_auto.js new file mode 100644 index 0000000000000..bd4d6e51ccc0d --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/get_bucket_size/calculate_auto.js @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +const d = moment.duration; + +const roundingRules = [ + [d(500, 'ms'), d(100, 'ms')], + [d(5, 'second'), d(1, 'second')], + [d(7.5, 'second'), d(5, 'second')], + [d(15, 'second'), d(10, 'second')], + [d(45, 'second'), d(30, 'second')], + [d(3, 'minute'), d(1, 'minute')], + [d(9, 'minute'), d(5, 'minute')], + [d(20, 'minute'), d(10, 'minute')], + [d(45, 'minute'), d(30, 'minute')], + [d(2, 'hour'), d(1, 'hour')], + [d(6, 'hour'), d(3, 'hour')], + [d(24, 'hour'), d(12, 'hour')], + [d(1, 'week'), d(1, 'd')], + [d(3, 'week'), d(1, 'week')], + [d(1, 'year'), d(1, 'month')], + [Infinity, d(1, 'year')], +]; + +const revRoundingRules = roundingRules.slice(0).reverse(); + +function find(rules, check, last) { + function pick(buckets, duration) { + const target = duration / buckets; + let lastResp = null; + + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + const resp = check(rule[0], rule[1], target); + + if (resp == null) { + if (!last) continue; + if (lastResp) return lastResp; + break; + } + + if (!last) return resp; + lastResp = resp; + } + + // fallback to just a number of milliseconds, ensure ms is >= 1 + const ms = Math.max(Math.floor(target), 1); + return moment.duration(ms, 'ms'); + } + + return (buckets, duration) => { + const interval = pick(buckets, duration); + if (interval) return moment.duration(interval._data); + }; +} + +export const calculateAuto = { + near: find( + revRoundingRules, + function near(bound, interval, target) { + if (bound > target) return interval; + }, + true + ), + + lessThan: find(revRoundingRules, function lessThan(_bound, interval, target) { + if (interval < target) return interval; + }), + + atLeast: find(revRoundingRules, function atLeast(_bound, interval, target) { + if (interval <= target) return interval; + }), +}; diff --git a/x-pack/plugins/exploratory_view/public/utils/get_bucket_size/index.test.ts b/x-pack/plugins/exploratory_view/public/utils/get_bucket_size/index.test.ts new file mode 100644 index 0000000000000..e91b6b44dee7f --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/get_bucket_size/index.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getBucketSize } from '.'; +import moment from 'moment'; + +describe('getBuckets', () => { + describe("minInterval 'auto'", () => { + it('last 15 minutes', () => { + const start = moment().subtract(15, 'minutes').valueOf(); + const end = moment.now(); + expect(getBucketSize({ start, end, minInterval: 'auto' })).toEqual({ + bucketSize: 10, + intervalString: '10s', + }); + }); + it('last 1 hour', () => { + const start = moment().subtract(1, 'hour').valueOf(); + const end = moment.now(); + expect(getBucketSize({ start, end, minInterval: 'auto' })).toEqual({ + bucketSize: 30, + intervalString: '30s', + }); + }); + it('last 1 week', () => { + const start = moment().subtract(1, 'week').valueOf(); + const end = moment.now(); + expect(getBucketSize({ start, end, minInterval: 'auto' })).toEqual({ + bucketSize: 3600, + intervalString: '3600s', + }); + }); + it('last 30 days', () => { + const start = moment().subtract(30, 'days').valueOf(); + const end = moment.now(); + expect(getBucketSize({ start, end, minInterval: 'auto' })).toEqual({ + bucketSize: 43200, + intervalString: '43200s', + }); + }); + it('last 1 year', () => { + const start = moment().subtract(1, 'year').valueOf(); + const end = moment.now(); + expect(getBucketSize({ start, end, minInterval: 'auto' })).toEqual({ + bucketSize: 86400, + intervalString: '86400s', + }); + }); + }); + describe("minInterval '30s'", () => { + it('last 15 minutes', () => { + const start = moment().subtract(15, 'minutes').valueOf(); + const end = moment.now(); + expect(getBucketSize({ start, end, minInterval: '30s' })).toEqual({ + bucketSize: 30, + intervalString: '30s', + }); + }); + it('last 1 year', () => { + const start = moment().subtract(1, 'year').valueOf(); + const end = moment.now(); + expect(getBucketSize({ start, end, minInterval: '30s' })).toEqual({ + bucketSize: 86400, + intervalString: '86400s', + }); + }); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/utils/get_bucket_size/index.ts b/x-pack/plugins/exploratory_view/public/utils/get_bucket_size/index.ts new file mode 100644 index 0000000000000..ca1afaf41c1a6 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/get_bucket_size/index.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +// @ts-ignore +import { calculateAuto } from './calculate_auto'; +import { unitToSeconds } from './unit_to_seconds'; + +export function getBucketSize({ + start, + end, + minInterval, + buckets = 100, +}: { + start: number; + end: number; + minInterval: string; + buckets?: number; +}) { + const duration = moment.duration(end - start, 'ms'); + const bucketSize = Math.max(calculateAuto.near(buckets, duration)?.asSeconds() ?? 0, 1); + const intervalString = `${bucketSize}s`; + const matches = minInterval && minInterval.match(/^([\d]+)([shmdwMy]|ms)$/); + const minBucketSize = matches ? Number(matches[1]) * unitToSeconds(matches[2]) : 0; + + if (bucketSize < minBucketSize) { + return { + bucketSize: minBucketSize, + intervalString: minInterval, + }; + } + + return { bucketSize, intervalString }; +} diff --git a/x-pack/plugins/exploratory_view/public/utils/get_bucket_size/unit_to_seconds.ts b/x-pack/plugins/exploratory_view/public/utils/get_bucket_size/unit_to_seconds.ts new file mode 100644 index 0000000000000..eec81dd3fcd29 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/get_bucket_size/unit_to_seconds.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment, { unitOfTime as UnitOfTIme } from 'moment'; + +function getDurationAsSeconds(value: number, unitOfTime: UnitOfTIme.Base) { + return moment.duration(value, unitOfTime).asSeconds(); +} + +const units = { + ms: getDurationAsSeconds(1, 'millisecond'), + s: getDurationAsSeconds(1, 'second'), + m: getDurationAsSeconds(1, 'minute'), + h: getDurationAsSeconds(1, 'hour'), + d: getDurationAsSeconds(1, 'day'), + w: getDurationAsSeconds(1, 'week'), + M: getDurationAsSeconds(1, 'month'), + y: getDurationAsSeconds(1, 'year'), +}; + +export function unitToSeconds(unit: string) { + return units[unit as keyof typeof units]; +} diff --git a/x-pack/plugins/exploratory_view/public/utils/get_time_zone.ts b/x-pack/plugins/exploratory_view/public/utils/get_time_zone.ts new file mode 100644 index 0000000000000..94a9dbda0369d --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/get_time_zone.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 { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; +import { UI_SETTINGS } from '../hooks/use_kibana_ui_settings'; + +export function getTimeZone(uiSettings?: IUiSettingsClient) { + const kibanaTimeZone = uiSettings?.get<'Browser' | string>(UI_SETTINGS.DATEFORMAT_TZ); + if (!kibanaTimeZone || kibanaTimeZone === 'Browser') { + return 'local'; + } + + return kibanaTimeZone; +} diff --git a/x-pack/plugins/exploratory_view/public/utils/kibana_react.mock.ts b/x-pack/plugins/exploratory_view/public/utils/kibana_react.mock.ts new file mode 100644 index 0000000000000..0e0c1149f7472 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/kibana_react.mock.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { coreMock, notificationServiceMock, overlayServiceMock } from '@kbn/core/public/mocks'; + +export const kibanaStartMock = { + startContract() { + return { + notifications: notificationServiceMock.createStartContract(), + overlays: overlayServiceMock.createStartContract(), + services: { + ...coreMock.createStart(), + storage: coreMock.createStorage(), + }, + }; + }, +}; diff --git a/x-pack/plugins/exploratory_view/public/utils/kibana_react.ts b/x-pack/plugins/exploratory_view/public/utils/kibana_react.ts new file mode 100644 index 0000000000000..1c731c02a36d8 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/kibana_react.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreStart } from '@kbn/core/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { ExploratoryViewPublicPluginsStart } from '../plugin'; + +export type StartServices = CoreStart & + ExploratoryViewPublicPluginsStart & + AdditionalServices & { + storage: Storage; + }; +const useTypedKibana = () => + useKibana>(); + +export { useTypedKibana as useKibana }; diff --git a/x-pack/plugins/exploratory_view/public/utils/navigation_warning_prompt/context.tsx b/x-pack/plugins/exploratory_view/public/utils/navigation_warning_prompt/context.tsx new file mode 100644 index 0000000000000..73e0502890728 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/navigation_warning_prompt/context.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, PropsWithChildren } from 'react'; +import { createContext, useContext } from 'react'; + +interface ContextValues { + prompt?: string; + setPrompt: (prompt: string | undefined) => void; +} + +export const NavigationWarningPromptContext = createContext({ + setPrompt: (prompt: string | undefined) => {}, +}); + +export const useNavigationWarningPrompt = () => { + return useContext(NavigationWarningPromptContext); +}; + +export function NavigationWarningPromptProvider({ children }: PropsWithChildren<{}>) { + const [prompt, setPrompt] = useState(undefined); + + return ( + + {children} + + ); +} diff --git a/x-pack/plugins/exploratory_view/public/utils/navigation_warning_prompt/index.ts b/x-pack/plugins/exploratory_view/public/utils/navigation_warning_prompt/index.ts new file mode 100644 index 0000000000000..52e14b066a72c --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/navigation_warning_prompt/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './context'; +export * from './prompt'; diff --git a/x-pack/plugins/exploratory_view/public/utils/navigation_warning_prompt/prompt.tsx b/x-pack/plugins/exploratory_view/public/utils/navigation_warning_prompt/prompt.tsx new file mode 100644 index 0000000000000..4ef6e6032d141 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/navigation_warning_prompt/prompt.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; +import { useNavigationWarningPrompt } from './context'; + +interface Props { + prompt?: string; +} + +export const Prompt: React.FC = ({ prompt }) => { + const { setPrompt } = useNavigationWarningPrompt(); + + useEffect(() => { + setPrompt(prompt); + return () => { + setPrompt(undefined); + }; + }, [prompt, setPrompt]); + + return null; +}; diff --git a/x-pack/plugins/exploratory_view/public/utils/no_data_config.ts b/x-pack/plugins/exploratory_view/public/utils/no_data_config.ts new file mode 100644 index 0000000000000..c9a55fafd927c --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/no_data_config.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { IBasePath } from '@kbn/core/public'; +import type { NoDataConfig } from '@kbn/shared-ux-page-kibana-template'; + +export function getNoDataConfig({ + docsLink, + basePath, + hasData, +}: { + docsLink: string; + basePath: IBasePath; + hasData?: boolean; +}): NoDataConfig | undefined { + if (hasData === false) { + return { + solution: i18n.translate('xpack.exploratoryView.noDataConfig.solutionName', { + defaultMessage: 'Observability', + }), + action: { + elasticAgent: { + title: i18n.translate('xpack.exploratoryView.noDataConfig.beatsCard.title', { + defaultMessage: 'Add integrations', + }), + description: i18n.translate('xpack.exploratoryView.noDataConfig.beatsCard.description', { + defaultMessage: + 'Use Beats and APM agents to send observability data to Elasticsearch. We make it easy with support for many popular systems, apps, and languages.', + }), + href: basePath.prepend(`/app/integrations/browse`), + }, + }, + docsLink, + }; + } +} diff --git a/x-pack/plugins/exploratory_view/public/utils/observability_data_views/get_app_data_view.ts b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/get_app_data_view.ts new file mode 100644 index 0000000000000..4b3af576f6d0d --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/get_app_data_view.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { AppDataType } from '../../components/shared/exploratory_view/types'; + +const getAppDataView = (data: DataViewsPublicPluginStart) => { + return async (appId: AppDataType, indexPattern?: string) => { + try { + const { ObservabilityDataViews } = await import('./observability_data_views'); + + const obsvIndexP = new ObservabilityDataViews(data); + return await obsvIndexP.getDataView(appId, indexPattern); + } catch (e) { + return null; + } + }; +}; + +// eslint-disable-next-line import/no-default-export +export default getAppDataView; diff --git a/x-pack/plugins/exploratory_view/public/utils/observability_data_views/index.ts b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/index.ts new file mode 100644 index 0000000000000..571f5ea4c11a2 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './observability_data_views'; diff --git a/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.test.ts b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.test.ts new file mode 100644 index 0000000000000..4b9b904b73fc4 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.test.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { dataViewList, ObservabilityDataViews } from './observability_data_views'; +import { mockCore, mockDataView } from '../../components/shared/exploratory_view/rtl_helpers'; +import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public'; + +const fieldFormats = { + 'transaction.duration.us': { + id: 'duration', + params: { + inputFormat: 'microseconds', + outputFormat: 'asSeconds', + outputPrecision: 1, + showSuffix: true, + useShortSuffix: true, + }, + }, + 'transaction.experience.fid': { + id: 'duration', + params: { + inputFormat: 'milliseconds', + outputFormat: 'humanizePrecise', + showSuffix: true, + useShortSuffix: true, + }, + }, + 'transaction.experience.tbt': { + id: 'duration', + params: { + inputFormat: 'milliseconds', + outputFormat: 'humanizePrecise', + showSuffix: true, + useShortSuffix: true, + }, + }, + 'transaction.marks.agent.firstContentfulPaint': { + id: 'duration', + params: { + inputFormat: 'milliseconds', + outputFormat: 'humanizePrecise', + showSuffix: true, + useShortSuffix: true, + }, + }, + 'transaction.marks.agent.largestContentfulPaint': { + id: 'duration', + params: { + inputFormat: 'milliseconds', + outputFormat: 'humanizePrecise', + showSuffix: true, + useShortSuffix: true, + }, + }, + 'transaction.marks.agent.timeToFirstByte': { + id: 'duration', + params: { + inputFormat: 'milliseconds', + outputFormat: 'humanizePrecise', + showSuffix: true, + useShortSuffix: true, + }, + }, +}; + +describe('ObservabilityDataViews', function () { + const { dataViews } = mockCore(); + dataViews!.get = jest.fn().mockReturnValue({ title: 'index-*' }); + dataViews!.createAndSave = jest.fn().mockReturnValue({ id: dataViewList.ux }); + dataViews!.create = jest.fn().mockReturnValue({ id: dataViewList.ux }); + dataViews!.updateSavedObject = jest.fn(); + + it('should return index pattern for app', async function () { + const obsv = new ObservabilityDataViews(dataViews!); + + const indexP = await obsv.getDataView('ux', 'heartbeat-8*,synthetics-*'); + + expect(indexP).toEqual({ id: 'rum_static_index_pattern_id' }); + + expect(dataViews?.get).toHaveBeenCalledWith( + 'rum_static_index_pattern_id_heartbeat_8_synthetics_' + ); + expect(dataViews?.get).toHaveBeenCalledTimes(1); + }); + + it('should creates missing index pattern', async function () { + dataViews!.get = jest.fn().mockImplementation(() => { + throw new SavedObjectNotFound('index_pattern'); + }); + + dataViews!.createAndSave = jest.fn().mockReturnValue({ id: dataViewList.ux }); + + const obsv = new ObservabilityDataViews(dataViews!); + + const indexP = await obsv.getDataView('ux', 'trace-*,apm-*'); + + expect(indexP).toEqual({ id: dataViewList.ux }); + + expect(dataViews?.createAndSave).toHaveBeenCalledWith({ + fieldFormats, + id: 'rum_static_index_pattern_id_trace_apm_', + timeFieldName: '@timestamp', + title: 'trace-*,apm-*', + name: 'User experience (RUM)', + }); + + expect(dataViews?.createAndSave).toHaveBeenCalledTimes(1); + }); + + it('should return getFieldFormats', function () { + const obsv = new ObservabilityDataViews(dataViews!); + + expect(obsv.getFieldFormats('ux')).toEqual(fieldFormats); + }); + + it('should validate field formats', async function () { + mockDataView.getFormatterForField = jest.fn().mockReturnValue({ params: () => {} }); + + const obsv = new ObservabilityDataViews(dataViews!); + + await obsv.validateFieldFormats('ux', mockDataView); + + expect(dataViews?.updateSavedObject).toHaveBeenCalledTimes(1); + expect(dataViews?.updateSavedObject).toHaveBeenCalledWith( + expect.objectContaining({ fieldFormatMap: fieldFormats }) + ); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.ts b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.ts new file mode 100644 index 0000000000000..c22a2ae6d1712 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.ts @@ -0,0 +1,256 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { FieldFormat as IFieldFormat } from '@kbn/field-formats-plugin/common'; +import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public'; +import type { + DataViewsPublicPluginStart, + DataView, + DataViewSpec, +} from '@kbn/data-views-plugin/public'; +import { RuntimeField } from '@kbn/data-views-plugin/public'; +import { DataViewMissingIndices } from '@kbn/data-views-plugin/common'; +import { DataTypesLabels } from '../../components/shared/exploratory_view/labels'; +import { syntheticsRuntimeFields } from '../../components/shared/exploratory_view/configurations/synthetics/runtime_fields'; +import { getApmDataViewTitle } from '../../components/shared/exploratory_view/utils/utils'; +import { rumFieldFormats } from '../../components/shared/exploratory_view/configurations/rum/field_formats'; +import { syntheticsFieldFormats } from '../../components/shared/exploratory_view/configurations/synthetics/field_formats'; +import { + AppDataType, + FieldFormat, + FieldFormatParams, +} from '../../components/shared/exploratory_view/types'; +import { apmFieldFormats } from '../../components/shared/exploratory_view/configurations/apm/field_formats'; +import { getDataHandler } from '../../data_handler'; +import { infraMetricsFieldFormats } from '../../components/shared/exploratory_view/configurations/infra_metrics/field_formats'; + +const appFieldFormats: Record = { + infra_logs: null, + infra_metrics: infraMetricsFieldFormats, + ux: rumFieldFormats, + apm: apmFieldFormats, + uptime: syntheticsFieldFormats, + synthetics: syntheticsFieldFormats, + mobile: apmFieldFormats, + alerts: null, +}; + +const appRuntimeFields: Record | null> = { + infra_logs: null, + infra_metrics: null, + ux: null, + apm: null, + uptime: syntheticsRuntimeFields, + synthetics: syntheticsRuntimeFields, + mobile: null, + alerts: null, +}; + +function getFieldFormatsForApp(app: AppDataType) { + return { runtimeFields: appRuntimeFields[app], formats: appFieldFormats[app] }; +} + +export const dataViewList: Record = { + synthetics: 'synthetics_static_index_pattern_id', + uptime: 'uptime_static_index_pattern_id', + apm: 'apm_static_index_pattern_id', + ux: 'rum_static_index_pattern_id', + infra_logs: 'infra_logs_static_index_pattern_id', + infra_metrics: 'infra_metrics_static_index_pattern_id', + mobile: 'mobile_static_index_pattern_id', + alerts: 'alerts_static_index_pattern_id', +}; + +const getAppIndicesWithPattern = (app: AppDataType, indices: string) => { + return `${indices}`; +}; + +const getAppDataViewId = (app: AppDataType, indices: string) => { + // Replace characters / ? , " < > | * with _ + const postfix = indices.replace(/[^A-Z0-9]+/gi, '_').toLowerCase(); + + return `${dataViewList?.[app] ?? app}_${postfix}`; +}; + +export async function getDataTypeIndices(dataType: AppDataType) { + switch (dataType) { + case 'synthetics': + return { + hasData: true, + indices: 'synthetics-*', + }; + case 'mobile': + case 'ux': + case 'apm': + const resultApm = await getDataHandler('apm')?.hasData(); + return { + hasData: Boolean(resultApm?.hasData), + indices: getApmDataViewTitle(resultApm?.indices), + }; + case 'alerts': + return { + hasData: true, + indices: '.alerts-observability*', + }; + default: + const resultUx = await getDataHandler(dataType)?.hasData(); + return { hasData: Boolean(resultUx?.hasData), indices: resultUx?.indices as string }; + } +} + +export function isParamsSame(param1: IFieldFormat['_params'], param2?: FieldFormatParams) { + const isSame = + param1?.inputFormat === param2?.inputFormat && + param1?.outputFormat === param2?.outputFormat && + param1?.useShortSuffix === param2?.useShortSuffix && + param1?.showSuffix === param2?.showSuffix; + + if (param2?.outputPrecision !== undefined) { + return param2.outputPrecision === param1?.outputPrecision && isSame; + } + + return isSame; +} + +export class ObservabilityDataViews { + dataViews: DataViewsPublicPluginStart; + adHocDataViews: boolean = false; + + constructor(dataViews: DataViewsPublicPluginStart, adHocDataViews?: boolean) { + this.dataViews = dataViews; + this.adHocDataViews = adHocDataViews ?? false; + } + + async createDataView(app: AppDataType, indices: string) { + const appIndicesPattern = getAppIndicesWithPattern(app, indices); + + const { runtimeFields } = getFieldFormatsForApp(app); + + const id = getAppDataViewId(app, indices); + + try { + const dataView = await this.dataViews.create( + { + id, + title: appIndicesPattern, + timeFieldName: '@timestamp', + fieldFormats: this.getFieldFormats(app), + name: DataTypesLabels[app], + }, + false, + false + ); + + if (runtimeFields !== null) { + runtimeFields.forEach(({ name, field }) => { + dataView.addRuntimeField(name, field); + }); + } + + return dataView; + } catch (e) { + if (e instanceof DataViewMissingIndices) { + this.dataViews.clearInstanceCache(id); + } + } + } + + async createAndSavedDataView(app: AppDataType, indices: string) { + const appIndicesPattern = getAppIndicesWithPattern(app, indices); + + const dataViewId = getAppDataViewId(app, indices); + + return await this.dataViews.createAndSave({ + title: appIndicesPattern, + id: dataViewId, + timeFieldName: '@timestamp', + fieldFormats: this.getFieldFormats(app), + name: DataTypesLabels[app], + }); + } + // we want to make sure field formats remain same + async validateFieldFormats(app: AppDataType, dataView: DataView) { + const { formats: defaultFieldFormats, runtimeFields } = getFieldFormatsForApp(app); + if (defaultFieldFormats && defaultFieldFormats.length > 0) { + let isParamsDifferent = false; + defaultFieldFormats.forEach(({ field, format }) => { + const fieldByName = dataView.getFieldByName(field); + if (fieldByName) { + const fieldFormat = dataView.getFormatterForField(fieldByName); + const params = fieldFormat.params(); + if (!isParamsSame(params, format.params) || format.id !== fieldFormat.type.id) { + dataView.setFieldFormat(field, format); + isParamsDifferent = true; + } + } + }); + let hasNewRuntimeField = false; + if (runtimeFields !== null) { + const allRunTimeFields = dataView.getAllRuntimeFields(); + runtimeFields.forEach(({ name, field }) => { + if (!allRunTimeFields[name]) { + hasNewRuntimeField = true; + dataView.addRuntimeField(name, field); + } + }); + } + if (isParamsDifferent || hasNewRuntimeField) { + await this.dataViews?.updateSavedObject(dataView); + } + } + } + + getFieldFormats(app: AppDataType) { + const fieldFormatMap: DataViewSpec['fieldFormats'] = {}; + + (appFieldFormats?.[app] ?? []).forEach(({ field, format }) => { + fieldFormatMap[field] = format; + }); + + return fieldFormatMap; + } + + async getDataView(app: AppDataType, indices?: string): Promise { + let appIndices = indices; + let hasData = false; + if (!appIndices) { + const { indices: indicesT, hasData: hData } = await getDataTypeIndices(app); + hasData = hData; + appIndices = indicesT; + } + + if (appIndices && (hasData || indices)) { + try { + const dataViewId = getAppDataViewId(app, appIndices); + const dataViewTitle = getAppIndicesWithPattern(app, appIndices); + // we will get the data view by id + + if (this.adHocDataViews) { + return await this.createDataView(app, appIndices); + } + + const dataView = await this.dataViews?.get(dataViewId); + + // and make sure title matches, otherwise, we will need to create it + if (dataView.title !== dataViewTitle) { + return await this.createAndSavedDataView(app, appIndices); + } + + // this is intentional a non blocking call, so no await clause + this.validateFieldFormats(app, dataView); + return dataView; + } catch (e: unknown) { + if (e instanceof SavedObjectNotFound) { + return await this.createAndSavedDataView(app, appIndices); + } + } + } + } +} + +// eslint-disable-next-line import/no-default-export +export default ObservabilityDataViews; diff --git a/x-pack/plugins/exploratory_view/public/utils/test_helper.tsx b/x-pack/plugins/exploratory_view/public/utils/test_helper.tsx new file mode 100644 index 0000000000000..95b9216c243d9 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/test_helper.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 { render as testLibRender } from '@testing-library/react'; +import { AppMountParameters } from '@kbn/core/public'; +import { coreMock } from '@kbn/core/public/mocks'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import translations from '@kbn/translations-plugin/translations/ja-JP.json'; +import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; + +import { PluginContext } from '../context/plugin_context'; + +const appMountParameters = { setHeaderActionMenu: () => {} } as unknown as AppMountParameters; + +export const core = coreMock.createStart(); +export const data = dataPluginMock.createStartContract(); + +export const render = (component: React.ReactNode = {}) => { + return testLibRender( + + + + {component} + + + + ); +}; diff --git a/x-pack/plugins/exploratory_view/public/utils/url.test.ts b/x-pack/plugins/exploratory_view/public/utils/url.test.ts new file mode 100644 index 0000000000000..7a28232f09254 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/url.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { toQuery, fromQuery } from './url'; + +describe('toQuery', () => { + it('should parse string to object', () => { + expect(toQuery('?foo=bar&name=john%20doe')).toEqual({ + foo: 'bar', + name: 'john doe', + }); + }); +}); + +describe('fromQuery', () => { + it('should not encode the following characters', () => { + expect( + fromQuery({ + a: true, + b: 5000, + c: ':', + }) + ).toEqual('a=true&b=5000&c=:'); + }); + + it('should encode the following characters', () => { + expect( + fromQuery({ + a: '@', + b: '.', + c: ';', + d: ' ', + }) + ).toEqual('a=%40&b=.&c=%3B&d=%20'); + }); + + it('should handle null and undefined', () => { + expect( + fromQuery({ + a: undefined, + b: null, + }) + ).toEqual('a=&b='); + }); + + it('should handle arrays', () => { + expect( + fromQuery({ + arr: ['a', 'b'], + }) + ).toEqual('arr=a%2Cb'); + }); + + it('should parse object to string', () => { + expect( + fromQuery({ + traceId: 'bar', + transactionId: 'john doe', + }) + ).toEqual('traceId=bar&transactionId=john%20doe'); + }); + + it('should not encode range params', () => { + expect( + fromQuery({ + rangeFrom: '2019-03-03T12:00:00.000Z', + rangeTo: '2019-03-05T12:00:00.000Z', + }) + ).toEqual('rangeFrom=2019-03-03T12:00:00.000Z&rangeTo=2019-03-05T12:00:00.000Z'); + }); + + it('should handle undefined, boolean, and number values without throwing errors', () => { + expect( + fromQuery({ + flyoutDetailTab: undefined, + refreshPaused: true, + refreshInterval: 5000, + }) + ).toEqual('flyoutDetailTab=&refreshPaused=true&refreshInterval=5000'); + }); +}); + +describe('fromQuery and toQuery', () => { + it('should encode and decode correctly', () => { + expect( + fromQuery(toQuery('?name=john%20doe&path=a%2Fb&rangeFrom=2019-03-03T12:00:00.000Z')) + ).toEqual('name=john%20doe&path=a%2Fb&rangeFrom=2019-03-03T12:00:00.000Z'); + }); +}); diff --git a/x-pack/plugins/exploratory_view/public/utils/url.ts b/x-pack/plugins/exploratory_view/public/utils/url.ts new file mode 100644 index 0000000000000..5dda5ce95c6e5 --- /dev/null +++ b/x-pack/plugins/exploratory_view/public/utils/url.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { parse, stringify } from 'query-string'; +import { url } from '@kbn/kibana-utils-plugin/public'; + +export function toQuery(search?: string) { + return search ? parse(search.slice(1), { sort: false }) : {}; +} + +export function fromQuery(query: Record) { + const encodedQuery = url.encodeQuery(query, (value) => + encodeURIComponent(value).replace(/%3A/g, ':') + ); + + return stringify(encodedQuery, { sort: false, encode: false }); +} diff --git a/x-pack/plugins/exploratory_view/scripts/e2e.js b/x-pack/plugins/exploratory_view/scripts/e2e.js new file mode 100644 index 0000000000000..85e6da4d3f60a --- /dev/null +++ b/x-pack/plugins/exploratory_view/scripts/e2e.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable no-console */ +const { executeSyntheticsRunner } = require('@kbn/synthetics-plugin/scripts/base_e2e'); +const path = require('path'); + +const e2eDir = path.join(__dirname, '../e2e'); + +executeSyntheticsRunner(e2eDir); diff --git a/x-pack/plugins/exploratory_view/scripts/storybook.js b/x-pack/plugins/exploratory_view/scripts/storybook.js new file mode 100644 index 0000000000000..c559e387cf090 --- /dev/null +++ b/x-pack/plugins/exploratory_view/scripts/storybook.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { join } from 'path'; + +require('@kbn/storybook').runStorybookCli({ + name: 'observability', + storyGlobs: [ + join(__dirname, '..', 'public', 'components', '**', '*.stories.tsx'), + join(__dirname, '..', 'public', 'pages', '**', '*.stories.tsx'), + ], +}); diff --git a/x-pack/plugins/exploratory_view/server/index.ts b/x-pack/plugins/exploratory_view/server/index.ts new file mode 100644 index 0000000000000..f4a5b93025e02 --- /dev/null +++ b/x-pack/plugins/exploratory_view/server/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor, PluginInitializerContext } from '@kbn/core/server'; +import { ExploratoryViewPlugin, ExploratoryViewPluginSetup } from './plugin'; +import { ScopedAnnotationsClient } from './lib/annotations/bootstrap_annotations'; + +const configSchema = schema.object({ + annotations: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + index: schema.string({ defaultValue: 'observability-annotations' }), + }), +}); + +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + unsafe: true, + }, + schema: configSchema, +}; + +export type ObservabilityConfig = TypeOf; + +export const plugin = (initContext: PluginInitializerContext) => + new ExploratoryViewPlugin(initContext); + +export type { ExploratoryViewPluginSetup as ObservabilityPluginSetup, ScopedAnnotationsClient }; diff --git a/x-pack/plugins/exploratory_view/server/lib/annotations/bootstrap_annotations.ts b/x-pack/plugins/exploratory_view/server/lib/annotations/bootstrap_annotations.ts new file mode 100644 index 0000000000000..cb3f350cb24f8 --- /dev/null +++ b/x-pack/plugins/exploratory_view/server/lib/annotations/bootstrap_annotations.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CoreSetup, + PluginInitializerContext, + KibanaRequest, + RequestHandlerContext, +} from '@kbn/core/server'; +import { LicensingApiRequestHandlerContext } from '@kbn/licensing-plugin/server'; +import { createAnnotationsClient } from './create_annotations_client'; +import { registerAnnotationAPIs } from './register_annotation_apis'; + +interface Params { + index: string; + core: CoreSetup; + context: PluginInitializerContext; +} + +export type ScopedAnnotationsClientFactory = Awaited< + ReturnType +>['getScopedAnnotationsClient']; + +export type ScopedAnnotationsClient = Awaited>; +export type AnnotationsAPI = Awaited>; + +export async function bootstrapAnnotations({ index, core, context }: Params) { + const logger = context.logger.get('annotations'); + + registerAnnotationAPIs({ + core, + index, + logger, + }); + + return { + getScopedAnnotationsClient: async ( + requestContext: RequestHandlerContext & { + licensing: Promise; + }, + request: KibanaRequest + ) => { + const esClient = (await requestContext.core).elasticsearch.client; + const { license } = await requestContext.licensing; + return createAnnotationsClient({ + index, + esClient: esClient.asCurrentUser, + logger, + license, + }); + }, + }; +} diff --git a/x-pack/plugins/exploratory_view/server/lib/annotations/create_annotations_client.ts b/x-pack/plugins/exploratory_view/server/lib/annotations/create_annotations_client.ts new file mode 100644 index 0000000000000..fc74ffa880fc2 --- /dev/null +++ b/x-pack/plugins/exploratory_view/server/lib/annotations/create_annotations_client.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient, Logger } from '@kbn/core/server'; +import * as t from 'io-ts'; +import Boom from '@hapi/boom'; +import { ILicense } from '@kbn/licensing-plugin/server'; +import { + createAnnotationRt, + deleteAnnotationRt, + Annotation, + getAnnotationByIdRt, +} from '../../../common/annotations'; +import { createOrUpdateIndex } from '../../utils/create_or_update_index'; +import { mappings } from './mappings'; +import { unwrapEsResponse } from '../../../common/utils/unwrap_es_response'; + +type CreateParams = t.TypeOf; +type DeleteParams = t.TypeOf; +type GetByIdParams = t.TypeOf; + +export function createAnnotationsClient(params: { + index: string; + esClient: ElasticsearchClient; + logger: Logger; + license?: ILicense; +}) { + const { index, esClient, logger, license } = params; + + const initIndex = () => + createOrUpdateIndex({ + index, + mappings, + client: esClient, + logger, + }); + + function ensureGoldLicense any>(fn: T): T { + return ((...args) => { + if (!license?.hasAtLeast('gold')) { + throw Boom.forbidden('Annotations require at least a gold license or a trial license.'); + } + return fn(...args); + }) as T; + } + + return { + get index() { + return index; + }, + create: ensureGoldLicense( + async ( + createParams: CreateParams + ): Promise<{ _id: string; _index: string; _source: Annotation }> => { + const indexExists = await unwrapEsResponse( + esClient.indices.exists( + { + index, + }, + { meta: true } + ) + ); + + if (!indexExists) { + await initIndex(); + } + + const annotation = { + ...createParams, + event: { + created: new Date().toISOString(), + }, + }; + + const body = await unwrapEsResponse( + esClient.index( + { + index, + body: annotation, + refresh: 'wait_for', + }, + { meta: true } + ) + ); + + return ( + await esClient.get( + { + index, + id: body._id, + }, + { meta: true } + ) + ).body as { _id: string; _index: string; _source: Annotation }; + } + ), + getById: ensureGoldLicense(async (getByIdParams: GetByIdParams) => { + const { id } = getByIdParams; + + return unwrapEsResponse( + esClient.get( + { + id, + index, + }, + { meta: true } + ) + ); + }), + delete: ensureGoldLicense(async (deleteParams: DeleteParams) => { + const { id } = deleteParams; + + return unwrapEsResponse( + esClient.delete( + { + index, + id, + refresh: 'wait_for', + }, + { meta: true } + ) + ); + }), + }; +} diff --git a/x-pack/plugins/exploratory_view/server/lib/annotations/mappings.ts b/x-pack/plugins/exploratory_view/server/lib/annotations/mappings.ts new file mode 100644 index 0000000000000..3313c411b5889 --- /dev/null +++ b/x-pack/plugins/exploratory_view/server/lib/annotations/mappings.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mappings = { + dynamic: 'strict', + properties: { + annotation: { + properties: { + type: { + type: 'keyword', + }, + }, + }, + message: { + type: 'text', + }, + tags: { + type: 'keyword', + }, + '@timestamp': { + type: 'date', + }, + event: { + properties: { + created: { + type: 'date', + }, + }, + }, + service: { + properties: { + name: { + type: 'keyword', + }, + environment: { + type: 'keyword', + }, + version: { + type: 'keyword', + }, + }, + }, + }, +} as const; diff --git a/x-pack/plugins/exploratory_view/server/lib/annotations/register_annotation_apis.ts b/x-pack/plugins/exploratory_view/server/lib/annotations/register_annotation_apis.ts new file mode 100644 index 0000000000000..6eb0ba6d6e96c --- /dev/null +++ b/x-pack/plugins/exploratory_view/server/lib/annotations/register_annotation_apis.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { schema } from '@kbn/config-schema'; +import { CoreSetup, RequestHandler, Logger } from '@kbn/core/server'; +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { isLeft } from 'fp-ts/lib/Either'; +import { + getAnnotationByIdRt, + createAnnotationRt, + deleteAnnotationRt, +} from '../../../common/annotations'; +import { ScopedAnnotationsClient } from './bootstrap_annotations'; +import { createAnnotationsClient } from './create_annotations_client'; +import type { ObservabilityRequestHandlerContext } from '../../types'; + +const unknowns = schema.object({}, { unknowns: 'allow' }); + +export function registerAnnotationAPIs({ + core, + index, + logger, +}: { + core: CoreSetup; + index: string; + logger: Logger; +}) { + function wrapRouteHandler>( + types: TType, + handler: (params: { data: t.TypeOf; client: ScopedAnnotationsClient }) => Promise + ): RequestHandler { + return async ( + ...args: Parameters< + RequestHandler + > + ) => { + const [context, request, response] = args; + + const rt = types; + + const data = { + body: request.body, + query: request.query, + params: request.params, + }; + + const validation = rt.decode(data); + + if (isLeft(validation)) { + return response.badRequest({ + body: PathReporter.report(validation).join(', '), + }); + } + + const esClient = (await context.core).elasticsearch.client.asCurrentUser; + const license = (await context.licensing)?.license; + + const client = createAnnotationsClient({ + index, + esClient, + logger, + license, + }); + + try { + const res = await handler({ + data: validation.right, + client, + }); + + return response.ok({ + body: res, + }); + } catch (err) { + return response.custom({ + statusCode: err.output?.statusCode ?? 500, + body: { + message: err.output?.payload?.message ?? 'An internal server error occured', + }, + }); + } + }; + } + + const router = core.http.createRouter(); + + router.post( + { + path: '/api/observability/annotation', + validate: { + body: unknowns, + }, + }, + wrapRouteHandler(t.type({ body: createAnnotationRt }), ({ data, client }) => { + return client.create(data.body); + }) + ); + + router.delete( + { + path: '/api/observability/annotation/{id}', + validate: { + params: unknowns, + }, + }, + wrapRouteHandler(t.type({ params: deleteAnnotationRt }), ({ data, client }) => { + return client.delete(data.params); + }) + ); + + router.get( + { + path: '/api/observability/annotation/{id}', + validate: { + params: unknowns, + }, + }, + wrapRouteHandler(t.type({ params: getAnnotationByIdRt }), ({ data, client }) => { + return client.getById(data.params); + }) + ); +} diff --git a/x-pack/plugins/exploratory_view/server/plugin.ts b/x-pack/plugins/exploratory_view/server/plugin.ts new file mode 100644 index 0000000000000..ee575e1cbfb96 --- /dev/null +++ b/x-pack/plugins/exploratory_view/server/plugin.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext, Plugin, CoreSetup } from '@kbn/core/server'; +import { SpacesPluginSetup } from '@kbn/spaces-plugin/server'; +import { + ScopedAnnotationsClientFactory, + AnnotationsAPI, +} from './lib/annotations/bootstrap_annotations'; + +export type ExploratoryViewPluginSetup = ReturnType; + +interface PluginSetup { + spaces?: SpacesPluginSetup; +} + +export class ExploratoryViewPlugin implements Plugin { + constructor(initContext: PluginInitializerContext) {} + + public setup(core: CoreSetup, plugins: PluginSetup) { + let annotationsApiPromise: Promise | undefined; + + /** + * Register a config for the observability guide + */ + + return { + getScopedAnnotationsClient: async (...args: Parameters) => { + const api = await annotationsApiPromise; + return api?.getScopedAnnotationsClient(...args); + }, + }; + } + + public start() {} + + public stop() {} +} diff --git a/x-pack/plugins/exploratory_view/server/types.ts b/x-pack/plugins/exploratory_view/server/types.ts new file mode 100644 index 0000000000000..e20fb73543add --- /dev/null +++ b/x-pack/plugins/exploratory_view/server/types.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { CustomRequestHandlerContext, CoreRequestHandlerContext } from '@kbn/core/server'; +import type { LicensingApiRequestHandlerContext } from '@kbn/licensing-plugin/server'; + +export type ObservabilityRequestHandlerContext = CustomRequestHandlerContext<{ + licensing: LicensingApiRequestHandlerContext; + core: Promise; +}>; diff --git a/x-pack/plugins/exploratory_view/server/utils/create_or_update_index.ts b/x-pack/plugins/exploratory_view/server/utils/create_or_update_index.ts new file mode 100644 index 0000000000000..eaea86c18b19f --- /dev/null +++ b/x-pack/plugins/exploratory_view/server/utils/create_or_update_index.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import pRetry from 'p-retry'; +import { Logger, ElasticsearchClient } from '@kbn/core/server'; + +export type Mappings = Required['body']['mappings'] & + Required['body']; + +export async function createOrUpdateIndex({ + index, + mappings, + client, + logger, +}: { + index: string; + mappings: Mappings; + client: ElasticsearchClient; + logger: Logger; +}) { + try { + /* + * In some cases we could be trying to create an index before ES is ready. + * When this happens, we retry creating the index with exponential backoff. + * We use retry's default formula, meaning that the first retry happens after 2s, + * the 5th after 32s, and the final attempt after around 17m. If the final attempt fails, + * the error is logged to the console. + * See https://github.com/sindresorhus/p-retry and https://github.com/tim-kos/node-retry. + */ + await pRetry( + async () => { + const indexExists = await client.indices.exists({ index }); + const result = indexExists + ? await updateExistingIndex({ + index, + client, + mappings, + }) + : await createNewIndex({ + index, + client, + mappings, + }); + + if (!result.acknowledged) { + const bodyWithError: { body?: { error: any } } = result as any; + const resultError = JSON.stringify(bodyWithError?.body?.error); + throw new Error(resultError); + } + }, + { + onFailedAttempt: (e) => { + logger.warn(`Could not create index: '${index}'. Retrying...`); + logger.warn(e); + }, + } + ); + } catch (e) { + logger.error(`Could not create index: '${index}'. Error: ${e.message}.`); + } +} + +function createNewIndex({ + index, + client, + mappings, +}: { + index: string; + client: ElasticsearchClient; + mappings: Required['body']['mappings']; +}) { + return client.indices.create({ + index, + body: { + // auto_expand_replicas: Allows cluster to not have replicas for this index + settings: { index: { auto_expand_replicas: '0-1' } }, + mappings, + }, + }); +} + +function updateExistingIndex({ + index, + client, + mappings, +}: { + index: string; + client: ElasticsearchClient; + mappings: estypes.IndicesPutMappingRequest['body']; +}) { + return client.indices.putMapping({ + index, + body: mappings, + }); +} diff --git a/x-pack/plugins/exploratory_view/tsconfig.json b/x-pack/plugins/exploratory_view/tsconfig.json new file mode 100644 index 0000000000000..6b740c0ea3e4c --- /dev/null +++ b/x-pack/plugins/exploratory_view/tsconfig.json @@ -0,0 +1,61 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": [ + "common/**/*", + "public/**/*", + "public/**/*.json", + "server/**/*", + "typings/**/*", + "../../../typings/**/*" + ], + "kbn_references": [ + "@kbn/core", + "@kbn/data-plugin", + "@kbn/home-plugin", + "@kbn/kibana-react-plugin", + "@kbn/kibana-utils-plugin", + "@kbn/usage-collection-plugin", + "@kbn/alerting-plugin", + "@kbn/licensing-plugin", + "@kbn/cases-plugin", + "@kbn/lens-plugin", + "@kbn/rule-registry-plugin", + "@kbn/spaces-plugin", + "@kbn/translations-plugin", + "@kbn/unified-search-plugin", + "@kbn/guided-onboarding-plugin", + "@kbn/discover-plugin", + "@kbn/i18n", + "@kbn/rule-data-utils", + "@kbn/inspector-plugin", + "@kbn/data-views-plugin", + "@kbn/embeddable-plugin", + "@kbn/triggers-actions-ui-plugin", + "@kbn/security-plugin", + "@kbn/shared-ux-page-kibana-template", + "@kbn/navigation-plugin", + "@kbn/i18n-react", + "@kbn/es-types", + "@kbn/rison", + "@kbn/analytics", + "@kbn/datemath", + "@kbn/core-ui-settings-browser", + "@kbn/es-query", + "@kbn/field-formats-plugin", + "@kbn/ui-theme", + "@kbn/coloring", + "@kbn/ui-actions-plugin", + "@kbn/visualizations-plugin", + "@kbn/core-http-browser", + "@kbn/config-schema", + "@kbn/share-plugin", + "@kbn/charts-plugin", + "@kbn/shared-ux-router", + "@kbn/core-application-browser", + "@kbn/observability-plugin" + ], + "exclude": ["target/**/*"] +} diff --git a/x-pack/plugins/exploratory_view/typings/common.ts b/x-pack/plugins/exploratory_view/typings/common.ts new file mode 100644 index 0000000000000..a6afbad78ad1e --- /dev/null +++ b/x-pack/plugins/exploratory_view/typings/common.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Request } from '@kbn/inspector-plugin/common'; + +export type ObservabilityApp = + | 'infra_metrics' + | 'infra_logs' + | 'apm' + // we will remove uptime in future to replace to be replace by synthetics + | 'uptime' + | 'synthetics' + | 'observability-overview' + | 'stack_monitoring' + | 'ux' + | 'fleet'; + +export type InspectResponse = Request[]; diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 694a7f7f0dd94..c55e2f8f658ca 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -80,6 +80,8 @@ import { AlertSummary } from './pages/alert_details/components/alert_summary'; import { AlertSummaryField } from './pages/alert_details/components/alert_summary'; export type { TopAlert, AlertSummary, AlertSummaryField }; +export { observabilityFeatureId, observabilityAppId } from '../common'; + export { useChartTheme } from './hooks/use_chart_theme'; export { useBreadcrumbs } from './hooks/use_breadcrumbs'; export { useTheme } from './hooks/use_theme'; diff --git a/x-pack/plugins/synthetics/kibana.jsonc b/x-pack/plugins/synthetics/kibana.jsonc index 036ee19d52ffc..79e31899b3dac 100644 --- a/x-pack/plugins/synthetics/kibana.jsonc +++ b/x-pack/plugins/synthetics/kibana.jsonc @@ -19,6 +19,7 @@ "discover", "dataViews", "encryptedSavedObjects", + "exploratoryView", "features", "inspector", "licensing", diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.test.tsx index b672e732c791e..96431b0df8a1b 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.test.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.test.tsx @@ -25,7 +25,7 @@ describe('ActionMenuContent', () => { 'Navigate to the "Explore Data" view to visualize Synthetics/User data' ); - expect(analyzeAnchor.getAttribute('href')).toContain('/app/observability/exploratory-view'); + expect(analyzeAnchor.getAttribute('href')).toContain('/app/exploratory-view'); expect(getByText('Explore data')); }); }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.tsx index a81b4a76eba99..f6702a05f75dd 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.tsx @@ -10,7 +10,7 @@ import { EuiHeaderLinks, EuiToolTip, EuiHeaderLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { useHistory, useRouteMatch } from 'react-router-dom'; -import { createExploratoryViewUrl } from '@kbn/observability-plugin/public'; +import { createExploratoryViewUrl } from '@kbn/exploratory-view-plugin/public'; import { LastRefreshed } from '../components/last_refreshed'; import { AutoRefreshButton } from '../components/auto_refresh_button'; import { useSyntheticsSettingsContext } from '../../../contexts'; diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/common/header/action_menu_content.test.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/common/header/action_menu_content.test.tsx index 89aaec5f133c2..9af5ff398e911 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/common/header/action_menu_content.test.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/common/header/action_menu_content.test.tsx @@ -38,7 +38,7 @@ describe('ActionMenuContent', () => { 'Navigate to the "Explore Data" view to visualize Synthetics/User data' ); - expect(analyzeAnchor.getAttribute('href')).toContain('/app/observability/exploratory-view'); + expect(analyzeAnchor.getAttribute('href')).toContain('/app/exploratory-view'); expect(getByText('Explore data')); }); diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/common/header/action_menu_content.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/common/header/action_menu_content.tsx index ad5551dfc00bf..311b35908bcd5 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/common/header/action_menu_content.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/common/header/action_menu_content.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { useHistory, useRouteMatch } from 'react-router-dom'; import { useSelector } from 'react-redux'; -import { createExploratoryViewUrl } from '@kbn/observability-plugin/public'; +import { createExploratoryViewUrl } from '@kbn/exploratory-view-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { stringifyUrlParams } from '../../../lib/helper/url_params/stringify_url_params'; import { useUptimeSettingsContext } from '../../../contexts/uptime_settings_context'; diff --git a/x-pack/plugins/synthetics/public/plugin.ts b/x-pack/plugins/synthetics/public/plugin.ts index 77de03480be75..5cd6943ea922a 100644 --- a/x-pack/plugins/synthetics/public/plugin.ts +++ b/x-pack/plugins/synthetics/public/plugin.ts @@ -20,6 +20,7 @@ import { DiscoverStart } from '@kbn/discover-plugin/public'; import { DEFAULT_APP_CATEGORIES } from '@kbn/core/public'; import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; +import type { ExploratoryViewPublicSetup } from '@kbn/exploratory-view-plugin/public'; import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; import { TriggersAndActionsUIPublicPluginSetup, @@ -62,6 +63,7 @@ import { syntheticsAlertTypeInitializers } from './apps/synthetics/lib/alert_typ export interface ClientPluginsSetup { home?: HomePublicPluginSetup; data: DataPublicPluginSetup; + exploratoryView: ExploratoryViewPublicSetup; observability: ObservabilityPublicSetup; share: SharePluginSetup; triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; @@ -144,6 +146,19 @@ export class UptimePlugin }, }); + plugins.exploratoryView.register({ + appName: 'uptime', + hasData: async () => { + const dataHelper = await getUptimeDataHelper(); + const status = await dataHelper.indexStatus(); + return { hasData: status.indexExists, indices: status.indices }; + }, + fetchData: async (params: FetchDataParams) => { + const dataHelper = await getUptimeDataHelper(); + return await dataHelper.overviewData(params); + }, + }); + registerUptimeRoutesWithNavigation(core, plugins); core.getStartServices().then(([coreStart, clientPluginsStart]) => {}); diff --git a/x-pack/plugins/synthetics/tsconfig.json b/x-pack/plugins/synthetics/tsconfig.json index 34b20082a0c07..1809306e08b7e 100644 --- a/x-pack/plugins/synthetics/tsconfig.json +++ b/x-pack/plugins/synthetics/tsconfig.json @@ -76,6 +76,7 @@ "@kbn/safer-lodash-set", "@kbn/shared-ux-router", "@kbn/alerts-as-data-utils", + "@kbn/exploratory-view-plugin", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/ux/kibana.jsonc b/x-pack/plugins/ux/kibana.jsonc index ccb1c35a8a2ae..b7ac64564a3e1 100644 --- a/x-pack/plugins/ux/kibana.jsonc +++ b/x-pack/plugins/ux/kibana.jsonc @@ -14,6 +14,7 @@ "features", "data", "dataViews", + "exploratoryView", "licensing", "triggersActionsUi", "embeddable", diff --git a/x-pack/plugins/ux/public/plugin.ts b/x-pack/plugins/ux/public/plugin.ts index 21c24effeb39d..6a370dddda3f7 100644 --- a/x-pack/plugins/ux/public/plugin.ts +++ b/x-pack/plugins/ux/public/plugin.ts @@ -30,6 +30,7 @@ import { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import { FeaturesPluginSetup } from '@kbn/features-plugin/public'; import { LicensingPluginSetup } from '@kbn/licensing-plugin/public'; import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; +import { ExploratoryViewPublicSetup } from '@kbn/exploratory-view-plugin/public'; import { MapsStartApi } from '@kbn/maps-plugin/public'; import { Start as InspectorPluginStart } from '@kbn/inspector-plugin/public'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; @@ -40,6 +41,7 @@ export type UxPluginStart = void; export interface ApmPluginSetupDeps { data: DataPublicPluginSetup; + exploratoryView: ExploratoryViewPublicSetup; features: FeaturesPluginSetup; home?: HomePublicPluginSetup; licensing: LicensingPluginSetup; @@ -97,6 +99,26 @@ export class UxPlugin implements Plugin { }); }, }); + + plugins.exploratoryView.register({ + appName: 'ux', + hasData: async (params?: HasDataParams) => { + const dataHelper = await getUxDataHelper(); + const dataStartPlugin = await getDataStartPlugin(core); + return dataHelper.hasRumData({ + ...params!, + dataStartPlugin, + }); + }, + fetchData: async (params: FetchDataParams) => { + const dataStartPlugin = await getDataStartPlugin(core); + const dataHelper = await getUxDataHelper(); + return dataHelper.fetchUxOverviewDate({ + ...params, + dataStartPlugin, + }); + }, + }); } // register observability nav if user has access to plugin diff --git a/x-pack/plugins/ux/tsconfig.json b/x-pack/plugins/ux/tsconfig.json index e9f31c6b08bee..336dbf9b94e5d 100644 --- a/x-pack/plugins/ux/tsconfig.json +++ b/x-pack/plugins/ux/tsconfig.json @@ -36,6 +36,7 @@ "@kbn/shared-ux-page-kibana-template", "@kbn/i18n-react", "@kbn/es-query", + "@kbn/exploratory-view-plugin", ], "exclude": [ "target/**/*", diff --git a/yarn.lock b/yarn.lock index 3b8ee9318c67b..c72382dced30d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4101,6 +4101,10 @@ version "0.0.0" uid "" +"@kbn/exploratory-view-plugin@link:x-pack/plugins/exploratory_view": + version "0.0.0" + uid "" + "@kbn/expression-error-plugin@link:src/plugins/expression_error": version "0.0.0" uid ""