[Observability] Copy Exploratory View into a separate app (#153852)
|
@ -56,6 +56,7 @@ disabled:
|
||||||
- x-pack/plugins/synthetics/e2e/synthetics_run.ts
|
- x-pack/plugins/synthetics/e2e/synthetics_run.ts
|
||||||
- x-pack/plugins/ux/e2e/synthetics_run.ts
|
- x-pack/plugins/ux/e2e/synthetics_run.ts
|
||||||
- x-pack/plugins/observability/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
|
# 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
|
- x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/config.ts
|
||||||
|
|
|
@ -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
|
|
@ -141,10 +141,15 @@ const uploadPipeline = (pipelineContent: string | object) => {
|
||||||
pipeline.push(getPipeline('.buildkite/pipelines/pull_request/observability_plugin.yml'));
|
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 (
|
if (
|
||||||
await doAnyChangesMatch([
|
await doAnyChangesMatch([
|
||||||
/^x-pack\/plugins\/synthetics/,
|
/^x-pack\/plugins\/synthetics/,
|
||||||
/^x-pack\/plugins\/observability\/public\/components\/shared\/exploratory_view/,
|
/^x-pack\/plugins\/observability\/public\/components\/shared\/exploratory_view/,
|
||||||
|
/^x-pack\/plugins\/exploratory_view/,
|
||||||
])
|
])
|
||||||
) {
|
) {
|
||||||
pipeline.push(getPipeline('.buildkite/pipelines/pull_request/synthetics_plugin.yml'));
|
pipeline.push(getPipeline('.buildkite/pipelines/pull_request/synthetics_plugin.yml'));
|
||||||
|
@ -154,6 +159,7 @@ const uploadPipeline = (pipelineContent: string | object) => {
|
||||||
await doAnyChangesMatch([
|
await doAnyChangesMatch([
|
||||||
/^x-pack\/plugins\/ux/,
|
/^x-pack\/plugins\/ux/,
|
||||||
/^x-pack\/plugins\/observability\/public\/components\/shared\/exploratory_view/,
|
/^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'));
|
pipeline.push(getPipeline('.buildkite/pipelines/pull_request/ux_plugin_e2e.yml'));
|
||||||
|
|
|
@ -867,6 +867,7 @@ module.exports = {
|
||||||
files: [
|
files: [
|
||||||
'x-pack/plugins/apm/**/*.{js,mjs,ts,tsx}',
|
'x-pack/plugins/apm/**/*.{js,mjs,ts,tsx}',
|
||||||
'x-pack/plugins/observability/**/*.{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/ux/**/*.{js,mjs,ts,tsx}',
|
||||||
],
|
],
|
||||||
rules: {
|
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: {
|
rules: {
|
||||||
'react/function-component-definition': [
|
'react/function-component-definition': [
|
||||||
'off',
|
'off',
|
||||||
|
@ -901,6 +906,7 @@ module.exports = {
|
||||||
files: [
|
files: [
|
||||||
'x-pack/plugins/apm/**/*.{js,mjs,ts,tsx}',
|
'x-pack/plugins/apm/**/*.{js,mjs,ts,tsx}',
|
||||||
'x-pack/plugins/observability/**/*.{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/ux/**/*.{js,mjs,ts,tsx}',
|
||||||
'x-pack/plugins/synthetics/**/*.{js,mjs,ts,tsx}',
|
'x-pack/plugins/synthetics/**/*.{js,mjs,ts,tsx}',
|
||||||
'x-pack/plugins/infra/**/*.{js,mjs,ts,tsx}',
|
'x-pack/plugins/infra/**/*.{js,mjs,ts,tsx}',
|
||||||
|
|
1
.github/CODEOWNERS
vendored
|
@ -344,6 +344,7 @@ x-pack/plugins/event_log @elastic/response-ops
|
||||||
packages/kbn-expandable-flyout @elastic/security-threat-hunting-investigations
|
packages/kbn-expandable-flyout @elastic/security-threat-hunting-investigations
|
||||||
packages/kbn-expect @elastic/kibana-operations
|
packages/kbn-expect @elastic/kibana-operations
|
||||||
x-pack/examples/exploratory_view_example @elastic/uptime
|
x-pack/examples/exploratory_view_example @elastic/uptime
|
||||||
|
x-pack/plugins/exploratory_view @elastic/uptime
|
||||||
src/plugins/expression_error @elastic/kibana-presentation
|
src/plugins/expression_error @elastic/kibana-presentation
|
||||||
src/plugins/chart_expressions/expression_gauge @elastic/kibana-visualizations
|
src/plugins/chart_expressions/expression_gauge @elastic/kibana-visualizations
|
||||||
src/plugins/chart_expressions/expression_heatmap @elastic/kibana-visualizations
|
src/plugins/chart_expressions/expression_heatmap @elastic/kibana-visualizations
|
||||||
|
|
|
@ -531,6 +531,10 @@ security and spaces filtering.
|
||||||
activities.
|
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]
|
|{kib-repo}blob/{branch}/x-pack/plugins/features/README.md[features]
|
||||||
|The features plugin enhance Kibana with a per-feature privilege system.
|
|The features plugin enhance Kibana with a per-feature privilege system.
|
||||||
|
|
||||||
|
|
|
@ -375,6 +375,7 @@
|
||||||
"@kbn/event-log-plugin": "link:x-pack/plugins/event_log",
|
"@kbn/event-log-plugin": "link:x-pack/plugins/event_log",
|
||||||
"@kbn/expandable-flyout": "link:packages/kbn-expandable-flyout",
|
"@kbn/expandable-flyout": "link:packages/kbn-expandable-flyout",
|
||||||
"@kbn/exploratory-view-example-plugin": "link:x-pack/examples/exploratory_view_example",
|
"@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-error-plugin": "link:src/plugins/expression_error",
|
||||||
"@kbn/expression-gauge-plugin": "link:src/plugins/chart_expressions/expression_gauge",
|
"@kbn/expression-gauge-plugin": "link:src/plugins/chart_expressions/expression_gauge",
|
||||||
"@kbn/expression-heatmap-plugin": "link:src/plugins/chart_expressions/expression_heatmap",
|
"@kbn/expression-heatmap-plugin": "link:src/plugins/chart_expressions/expression_heatmap",
|
||||||
|
|
|
@ -14,7 +14,7 @@ module.exports = {
|
||||||
USES_STYLED_COMPONENTS: [
|
USES_STYLED_COMPONENTS: [
|
||||||
/packages[\/\\](kbn-ui-shared-deps-(npm|src)|kbn-ecs-data-quality-dashboard)[\/\\]/,
|
/packages[\/\\](kbn-ui-shared-deps-(npm|src)|kbn-ecs-data-quality-dashboard)[\/\\]/,
|
||||||
/src[\/\\]plugins[\/\\](kibana_react)[\/\\]/,
|
/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[\/\\]/,
|
/x-pack[\/\\]test[\/\\]plugin_functional[\/\\]plugins[\/\\]resolver_test[\/\\]/,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -40,6 +40,7 @@ pageLoadAssetSize:
|
||||||
enterpriseSearch: 35741
|
enterpriseSearch: 35741
|
||||||
esUiShared: 326654
|
esUiShared: 326654
|
||||||
eventAnnotation: 20500
|
eventAnnotation: 20500
|
||||||
|
exploratoryView: 74673
|
||||||
expressionError: 22127
|
expressionError: 22127
|
||||||
expressionGauge: 25000
|
expressionGauge: 25000
|
||||||
expressionHeatmap: 27505
|
expressionHeatmap: 27505
|
||||||
|
|
|
@ -22,6 +22,7 @@ export const TS_PROJECTS = TsProject.loadAll({
|
||||||
'x-pack/plugins/synthetics/e2e/tsconfig.json',
|
'x-pack/plugins/synthetics/e2e/tsconfig.json',
|
||||||
'x-pack/plugins/ux/e2e/tsconfig.json',
|
'x-pack/plugins/ux/e2e/tsconfig.json',
|
||||||
'x-pack/plugins/observability/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',
|
'x-pack/plugins/threat_intelligence/cypress/tsconfig.json',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
|
@ -152,6 +152,7 @@ export const applicationUsageSchema = {
|
||||||
ml: commonSchema,
|
ml: commonSchema,
|
||||||
monitoring: commonSchema,
|
monitoring: commonSchema,
|
||||||
'observability-overview': commonSchema,
|
'observability-overview': commonSchema,
|
||||||
|
'exploratory-view': commonSchema,
|
||||||
osquery: commonSchema,
|
osquery: commonSchema,
|
||||||
profiling: commonSchema,
|
profiling: commonSchema,
|
||||||
security_account: commonSchema,
|
security_account: commonSchema,
|
||||||
|
|
|
@ -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": {
|
"osquery": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"appId": {
|
"appId": {
|
||||||
|
|
|
@ -682,6 +682,8 @@
|
||||||
"@kbn/expect/*": ["packages/kbn-expect/*"],
|
"@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-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-error-plugin/*": ["src/plugins/expression_error/*"],
|
"@kbn/expression-error-plugin/*": ["src/plugins/expression_error/*"],
|
||||||
"@kbn/expression-gauge-plugin": ["src/plugins/chart_expressions/expression_gauge"],
|
"@kbn/expression-gauge-plugin": ["src/plugins/chart_expressions/expression_gauge"],
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
"xpack.enterpriseSearch": "plugins/enterprise_search",
|
"xpack.enterpriseSearch": "plugins/enterprise_search",
|
||||||
"xpack.features": "plugins/features",
|
"xpack.features": "plugins/features",
|
||||||
"xpack.dataVisualizer": "plugins/data_visualizer",
|
"xpack.dataVisualizer": "plugins/data_visualizer",
|
||||||
|
"xpack.exploratoryView": "plugins/exploratory_view",
|
||||||
"xpack.fileUpload": "plugins/file_upload",
|
"xpack.fileUpload": "plugins/file_upload",
|
||||||
"xpack.globalSearch": ["plugins/global_search"],
|
"xpack.globalSearch": ["plugins/global_search"],
|
||||||
"xpack.globalSearchBar": ["plugins/global_search_bar"],
|
"xpack.globalSearchBar": ["plugins/global_search_bar"],
|
||||||
|
|
11
x-pack/plugins/exploratory_view/.storybook/jest_setup.js
Normal file
|
@ -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);
|
8
x-pack/plugins/exploratory_view/.storybook/main.js
Normal file
|
@ -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;
|
10
x-pack/plugins/exploratory_view/.storybook/preview.js
Normal file
|
@ -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];
|
27
x-pack/plugins/exploratory_view/README.md
Normal file
|
@ -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
|
||||||
|
```
|
69
x-pack/plugins/exploratory_view/common/annotations.ts
Normal file
|
@ -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<string, string, unknown>(
|
||||||
|
'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;
|
||||||
|
}
|
8
x-pack/plugins/exploratory_view/common/constants.ts
Normal file
|
@ -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';
|
|
@ -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;
|
|
@ -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';
|
|
@ -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,
|
||||||
|
];
|
12
x-pack/plugins/exploratory_view/common/i18n.ts
Normal file
|
@ -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',
|
||||||
|
});
|
75
x-pack/plugins/exploratory_view/common/index.ts
Normal file
|
@ -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';
|
13
x-pack/plugins/exploratory_view/common/processor_event.ts
Normal file
|
@ -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',
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
38
x-pack/plugins/exploratory_view/common/typings.ts
Normal file
|
@ -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> = T | null | undefined;
|
||||||
|
|
||||||
|
export const alertWorkflowStatusRt = t.keyof({
|
||||||
|
open: null,
|
||||||
|
acknowledged: null,
|
||||||
|
closed: null,
|
||||||
|
});
|
||||||
|
export type AlertWorkflowStatus = t.TypeOf<typeof alertWorkflowStatusRt>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
27
x-pack/plugins/exploratory_view/common/ui_settings_keys.ts
Normal file
|
@ -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';
|
|
@ -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 = <T extends any[]>(array: T): Array<ValuesType<T>> => {
|
||||||
|
return array;
|
||||||
|
};
|
|
@ -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<any> type is not compatible
|
||||||
|
// with Array<any>. This function returns a mutable version of a type.
|
||||||
|
|
||||||
|
export function asMutableArray<T extends Readonly<any>>(
|
||||||
|
arr: T
|
||||||
|
): T extends Readonly<[...infer U]> ? U : unknown[] {
|
||||||
|
return arr as any;
|
||||||
|
}
|
|
@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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}`;
|
||||||
|
}
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<number>, 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<number>;
|
||||||
|
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<number>) {
|
||||||
|
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<number>,
|
||||||
|
{ 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<number>) {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
};
|
|
@ -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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<number>,
|
||||||
|
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);
|
||||||
|
}
|
|
@ -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';
|
|
@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<number>) => {
|
||||||
|
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;
|
||||||
|
};
|
|
@ -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<string, any>;
|
||||||
|
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<string, any>;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -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: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<T extends Record<string, any>, U extends UnionToIntersection<T>> = Array<
|
||||||
|
Partial<U> & {
|
||||||
|
[k in keyof T]: T[k];
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ArrayOrSingle<T> = T | T[];
|
||||||
|
|
||||||
|
export function joinByKey<
|
||||||
|
T extends Record<string, any>,
|
||||||
|
U extends UnionToIntersection<T>,
|
||||||
|
V extends ArrayOrSingle<keyof T & keyof U>
|
||||||
|
>(items: T[], key: V): JoinedReturnType<T, U>;
|
||||||
|
|
||||||
|
export function joinByKey<
|
||||||
|
T extends Record<string, any>,
|
||||||
|
U extends UnionToIntersection<T>,
|
||||||
|
V extends ArrayOrSingle<keyof T & keyof U>,
|
||||||
|
W extends JoinedReturnType<T, U>,
|
||||||
|
X extends (a: T, b: T) => ValuesType<W>
|
||||||
|
>(items: T[], key: V, mergeFn: X): W;
|
||||||
|
|
||||||
|
export function joinByKey(
|
||||||
|
items: Array<Record<string, any>>,
|
||||||
|
key: string | string[],
|
||||||
|
mergeFn: Function = (a: Record<string, any>, b: Record<string, any>) => merge({}, a, b)
|
||||||
|
) {
|
||||||
|
const keys = castArray(key);
|
||||||
|
return items.reduce<Array<Record<string, any>>>((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;
|
||||||
|
}, []);
|
||||||
|
}
|
10
x-pack/plugins/exploratory_view/common/utils/maybe.ts
Normal file
|
@ -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<T>(value: T): T | null | undefined {
|
||||||
|
return value;
|
||||||
|
}
|
12
x-pack/plugins/exploratory_view/common/utils/pick_keys.ts
Normal file
|
@ -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<T, K extends keyof T>(obj: T, ...keys: K[]) {
|
||||||
|
return pick(obj, keys) as Pick<T, K>;
|
||||||
|
}
|
|
@ -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<T extends Promise<{ body: any }>>(
|
||||||
|
responsePromise: T
|
||||||
|
): Promise<Awaited<T>['body']> {
|
||||||
|
return responsePromise
|
||||||
|
.then((res) => res.body)
|
||||||
|
.catch((err) => {
|
||||||
|
// make sure stacktrace is relative to where client was called
|
||||||
|
throw new WrappedElasticsearchClientError(err);
|
||||||
|
});
|
||||||
|
}
|
15
x-pack/plugins/exploratory_view/e2e/README.md
Normal file
|
@ -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.
|
108
x-pack/plugins/exploratory_view/e2e/journeys/exploratory_view.ts
Normal file
|
@ -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"]');
|
||||||
|
});
|
||||||
|
});
|
10
x-pack/plugins/exploratory_view/e2e/journeys/index.ts
Normal file
|
@ -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';
|
|
@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
33
x-pack/plugins/exploratory_view/e2e/parse_args_params.ts
Normal file
|
@ -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 };
|
32
x-pack/plugins/exploratory_view/e2e/record_video.ts
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
45
x-pack/plugins/exploratory_view/e2e/synthetics_run.ts
Normal file
|
@ -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;
|
155
x-pack/plugins/exploratory_view/e2e/synthetics_runner.ts
Normal file
|
@ -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<void>;
|
||||||
|
|
||||||
|
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<void>, 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<ReturnType<typeof syntheticsRun>> = {};
|
||||||
|
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<ReturnType<typeof syntheticsRun>>) {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
37
x-pack/plugins/exploratory_view/e2e/tasks/es_archiver.ts
Normal file
|
@ -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' }
|
||||||
|
);
|
||||||
|
};
|
229
x-pack/plugins/exploratory_view/e2e/test_reporter.ts
Normal file
|
@ -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<string, Array<StepEndResult & { name: string }>> = 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;
|
||||||
|
}
|
9
x-pack/plugins/exploratory_view/e2e/tsconfig.json
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "../../../../tsconfig.base.json",
|
||||||
|
"exclude": ["tmp", "target/**/*"],
|
||||||
|
"include": ["./**/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "target/types",
|
||||||
|
"types": [ "node"],
|
||||||
|
},
|
||||||
|
}
|
58
x-pack/plugins/exploratory_view/e2e/utils.ts
Normal file
|
@ -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,
|
||||||
|
};
|
18
x-pack/plugins/exploratory_view/jest.config.js
Normal file
|
@ -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: ['<rootDir>/x-pack/plugins/exploratory_view'],
|
||||||
|
setupFiles: ['<rootDir>/x-pack/plugins/exploratory_view/.storybook/jest_setup.js'],
|
||||||
|
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/x-pack/plugins/exploratory_view',
|
||||||
|
coverageReporters: ['text', 'html'],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'<rootDir>/x-pack/plugins/exploratory_view/{common,public,server}/**/*.{js,ts,tsx}',
|
||||||
|
],
|
||||||
|
};
|
49
x-pack/plugins/exploratory_view/kibana.jsonc
Normal file
|
@ -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"]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
113
x-pack/plugins/exploratory_view/public/application/index.tsx
Normal file
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<Switch>
|
||||||
|
{Object.keys(routes).map((key) => {
|
||||||
|
const path = key as keyof typeof routes;
|
||||||
|
const { handler, exact } = routes[path];
|
||||||
|
const Wrapper = () => {
|
||||||
|
return handler();
|
||||||
|
};
|
||||||
|
return <Route key={path} path={path} exact={exact} component={Wrapper} />;
|
||||||
|
})}
|
||||||
|
</Switch>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
<EuiErrorBoundary>
|
||||||
|
<ApplicationUsageTrackingProvider>
|
||||||
|
<KibanaThemeProvider theme$={theme$}>
|
||||||
|
<KibanaContextProvider
|
||||||
|
services={{
|
||||||
|
...core,
|
||||||
|
...plugins,
|
||||||
|
storage: new Storage(localStorage),
|
||||||
|
isDev,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PluginContext.Provider
|
||||||
|
value={{
|
||||||
|
appMountParameters,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Router history={history}>
|
||||||
|
<EuiThemeProvider darkMode={isDarkMode}>
|
||||||
|
<i18nCore.Context>
|
||||||
|
<RedirectAppLinks application={core.application} className={APP_WRAPPER_CLASS}>
|
||||||
|
<HasDataContextProvider>
|
||||||
|
<App />
|
||||||
|
</HasDataContextProvider>
|
||||||
|
</RedirectAppLinks>
|
||||||
|
</i18nCore.Context>
|
||||||
|
</EuiThemeProvider>
|
||||||
|
</Router>
|
||||||
|
</PluginContext.Provider>
|
||||||
|
</KibanaContextProvider>
|
||||||
|
</KibanaThemeProvider>
|
||||||
|
</ApplicationUsageTrackingProvider>
|
||||||
|
</EuiErrorBoundary>,
|
||||||
|
element
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
ReactDOM.unmountComponentAtNode(element);
|
||||||
|
};
|
||||||
|
};
|
52
x-pack/plugins/exploratory_view/public/application/types.ts
Normal file
|
@ -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;
|
||||||
|
}
|
After Width: | Height: | Size: 149 KiB |
After Width: | Height: | Size: 150 KiB |
|
@ -0,0 +1,116 @@
|
||||||
|
<svg width="568" height="320" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#25262E" d="M0 0h568v320H0z"/>
|
||||||
|
<g filter="url(#kibana_dashboard_dark__filter0_d)">
|
||||||
|
<rect x="24" y="172" width="248" height="124" rx="4" fill="#1D1E24"/>
|
||||||
|
</g>
|
||||||
|
<rect x="32" y="180" width="120" height="4" rx="2" fill="#A7AFBE"/>
|
||||||
|
<rect x="40" y="200" width="16" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="72" y="284" width="24" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="125" y="284" width="24" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="178" y="284" width="24" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="231" y="284" width="24" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="32" y="221" width="24" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="36" y="242" width="20" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="40" y="263" width="16" height="4" rx="2" transform="rotate(-.17 40 263)" fill="#535966"/>
|
||||||
|
<path d="M64.5 192.5v9.5m199 73.5H243M64.5 202h-4m4 0v21m0 0h-4m4 0v21m0 0h-4m4 0v21m0 0v10.5H84M64.5 265h-4M84 279.5v-4m0 0h53m0 0v4m0-4h53m0 0v4m0-4h53m0 0v4" stroke="#343741" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<rect x="67" y="194" width="181" height="16" rx="1" fill="#54B399"/>
|
||||||
|
<rect x="67" y="236" width="59" height="16" rx="1" fill="#D36086"/>
|
||||||
|
<rect x="67" y="215" width="120" height="16" rx="1" fill="#6092C0"/>
|
||||||
|
<rect x="67" y="257" width="30" height="16" rx="1" fill="#9170B8"/>
|
||||||
|
<g filter="url(#kibana_dashboard_dark__filter1_d)">
|
||||||
|
<rect x="24" y="24" width="520" height="124" rx="4" fill="#1D1E24"/>
|
||||||
|
</g>
|
||||||
|
<rect x="32" y="32" width="101" height="4" rx="2" fill="#A7AFBE"/>
|
||||||
|
<rect x="38" y="52" width="18" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="32" y="73" width="24" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="40" y="94" width="16" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="36" y="115" width="20" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="72" y="136" width="24" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="216" y="136" width="24" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="360" y="136" width="24" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="504" y="136" width="24" height="4" rx="2" fill="#535966"/>
|
||||||
|
<path opacity=".1" d="M80.177 118L65 110v17h470.5V90.5l-15.177 12.692-15.178 1.346-15.177-8.846-15.178 17.885-15.177.961-15.178-5.384-15.177-.769-15.177 5.384-15.178-5.846-15.177-40.615-15.178 18.077-15.177-38.462L338.194 104l-15.178-16.308-15.177 12.923-15.178-9.538-15.177 5.615-15.178-24.384L247.129 71l-15.177-15.615-15.178 49.077-15.177 4.615-15.178-22.154-15.177-5.385L156.065 50l-15.178 37.923-15.177 6.923-15.178 19.692-15.177-4.692L80.177 118z" fill="#54B399"/>
|
||||||
|
<path d="M65 110l15.177 8 15.178-8.154 15.177 4.692 15.178-19.692 15.177-6.923L156.065 50l15.177 31.538 15.177 5.385 15.178 22.154 15.177-4.615 15.178-49.077L247.129 71l15.177 1.308 15.178 24.384 15.177-5.615 15.178 9.538 15.177-12.923L338.194 104l15.177-57.077 15.177 38.462 15.178-18.077 15.177 40.615 15.178 5.846 15.177-5.384 15.177.769 15.178 5.384 15.177-.961 15.178-17.885 15.177 8.846 15.178-1.346L535.5 90.5" stroke="#54B399" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle opacity=".1" cx="100" cy="63" r="8" fill="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="232" cy="55" r="8" fill="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="323" cy="88" r="8" fill="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="353" cy="47" r="7.5" fill="#6092C0" stroke="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="523" cy="47" r="8" fill="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="106" cy="68" r="4" fill="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="156" cy="50" r="4" fill="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="240" cy="55" r="4" fill="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="262" cy="72" r="4" fill="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="345" cy="48" r="4" fill="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="484" cy="92" r="3.5" fill="#6092C0" stroke="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="490" cy="96" r="4" fill="#6092C0"/>
|
||||||
|
<circle cx="100" cy="63" r="7.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="232" cy="55" r="7.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="323" cy="88" r="7.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="353" cy="47" r="7.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="523" cy="47" r="7.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="106" cy="68" r="3.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="156" cy="50" r="3.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="240" cy="55" r="3.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="262" cy="72" r="3.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="345" cy="48" r="3.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="484" cy="92" r="3.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="490" cy="96" r="3.5" stroke="#6092C0"/>
|
||||||
|
<path d="M64.5 44.5V54m471 73.5H516M64.5 54h-4m4 0v21m0 0h-4m4 0v21m0 0h-4m4 0v21m0 0v10.5H84M64.5 117h-4M84 131.5v-4m0 0h144m0 0v4m0-4h144m0 0v4m0-4h144m0 0v4" stroke="#343741" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<g filter="url(#kibana_dashboard_dark__filter2_d)">
|
||||||
|
<rect x="296" y="172" width="248" height="124" rx="4" fill="#1D1E24"/>
|
||||||
|
</g>
|
||||||
|
<rect x="304" y="180" width="80" height="4" rx="2" fill="#A7AFBE"/>
|
||||||
|
<path d="M448.284 268.284a39.997 39.997 0 00-10.054-63.888 39.997 39.997 0 00-24.383-3.92l2.461 15.81a24 24 0 0120.663 40.685l11.313 11.313z" fill="#54B399"/>
|
||||||
|
<mask id="a" maskUnits="userSpaceOnUse" x="385" y="250" width="72" height="31" fill="#000">
|
||||||
|
<path fill="#fff" d="M385 250h72v31h-72z"/>
|
||||||
|
<path d="M386.416 261.728a39.996 39.996 0 0034.591 18.259 39.994 39.994 0 0033.628-19.976l-13.854-8.005a24 24 0 01-40.932 1.031l-13.433 8.691z"/>
|
||||||
|
</mask>
|
||||||
|
<path d="M386.416 261.728a39.996 39.996 0 0034.591 18.259 39.994 39.994 0 0033.628-19.976l-13.854-8.005a24 24 0 01-40.932 1.031l-13.433 8.691z" fill="#6092C0"/>
|
||||||
|
<path d="M386.416 261.728a39.996 39.996 0 0034.591 18.259 39.994 39.994 0 0033.628-19.976l-13.854-8.005a24 24 0 01-40.932 1.031l-13.433 8.691z" stroke="#1D1E24" stroke-width="2" mask="url(#a)"/>
|
||||||
|
<mask id="b" maskUnits="userSpaceOnUse" x="379" y="213" width="24" height="54" fill="#000">
|
||||||
|
<path fill="#fff" d="M379 213h24v54h-24z"/>
|
||||||
|
<path d="M388.645 215.163a40 40 0 00.54 50.341l12.326-10.202a24 24 0 01-.324-30.204l-12.542-9.935z"/>
|
||||||
|
</mask>
|
||||||
|
<path d="M388.645 215.163a40 40 0 00.54 50.341l12.326-10.202a24 24 0 01-.324-30.204l-12.542-9.935z" fill="#D36086"/>
|
||||||
|
<path d="M388.645 215.163a40 40 0 00.54 50.341l12.326-10.202a24 24 0 01-.324-30.204l-12.542-9.935z" stroke="#1D1E24" stroke-width="2" mask="url(#b)"/>
|
||||||
|
<mask id="c" maskUnits="userSpaceOnUse" x="384" y="199" width="37" height="30" fill="#000">
|
||||||
|
<path fill="#fff" d="M384 199h37v30h-37z"/>
|
||||||
|
<path d="M420 200a40.005 40.005 0 00-33.929 18.814l13.572 8.474A24.004 24.004 0 01420 216v-16z"/>
|
||||||
|
</mask>
|
||||||
|
<path d="M420 200a40.005 40.005 0 00-33.929 18.814l13.572 8.474A24.004 24.004 0 01420 216v-16z" fill="#9170B8"/>
|
||||||
|
<path d="M420 200a40.005 40.005 0 00-33.929 18.814l13.572 8.474A24.004 24.004 0 01420 216v-16z" stroke="#1D1E24" stroke-width="2" mask="url(#c)"/>
|
||||||
|
<rect x="367" y="192" width="22" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="345" y="250" width="16" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="441" y="284" width="24" height="4" rx="2" fill="#535966"/>
|
||||||
|
<rect x="471" y="210" width="24" height="4" rx="2" fill="#535966"/>
|
||||||
|
<path d="M393.5 194h4l4 8.5m65 9.5h-4l-7 4.5m-26.5 64l3.5 5.5h4m-58-41l-9 7h-4" stroke="#343741" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="kibana_dashboard_dark__filter0_d" x="8" y="160" width="280" height="156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||||
|
<feOffset dy="4"/>
|
||||||
|
<feGaussianBlur stdDeviation="8"/>
|
||||||
|
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
|
||||||
|
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||||
|
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="kibana_dashboard_dark__filter1_d" x="8" y="12" width="552" height="156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||||
|
<feOffset dy="4"/>
|
||||||
|
<feGaussianBlur stdDeviation="8"/>
|
||||||
|
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
|
||||||
|
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||||
|
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="kibana_dashboard_dark__filter2_d" x="280" y="160" width="280" height="156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||||
|
<feOffset dy="4"/>
|
||||||
|
<feGaussianBlur stdDeviation="8"/>
|
||||||
|
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
|
||||||
|
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||||
|
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 9 KiB |
|
@ -0,0 +1,116 @@
|
||||||
|
<svg width="568" height="320" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#F5F7FA" d="M0 0h568v320H0z"/>
|
||||||
|
<g filter="url(#kibana_dashboard_light__filter0_d)">
|
||||||
|
<rect x="24" y="172" width="248" height="124" rx="4" fill="#fff"/>
|
||||||
|
</g>
|
||||||
|
<rect x="32" y="180" width="120" height="4" rx="2" fill="#6A717D"/>
|
||||||
|
<rect x="40" y="200" width="16" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="72" y="284" width="24" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="125" y="284" width="24" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="178" y="284" width="24" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="231" y="284" width="24" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="32" y="221" width="24" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="36" y="242" width="20" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="40" y="263" width="16" height="4" rx="2" transform="rotate(-.17 40 263)" fill="#98A2B3"/>
|
||||||
|
<path d="M64.5 192.5v9.5m199 73.5H243M64.5 202h-4m4 0v21m0 0h-4m4 0v21m0 0h-4m4 0v21m0 0v10.5H84M64.5 265h-4M84 279.5v-4m0 0h53m0 0v4m0-4h53m0 0v4m0-4h53m0 0v4" stroke="#D3DAE6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<rect x="67" y="194" width="181" height="16" rx="1" fill="#54B399"/>
|
||||||
|
<rect x="67" y="236" width="59" height="16" rx="1" fill="#D36086"/>
|
||||||
|
<rect x="67" y="215" width="120" height="16" rx="1" fill="#6092C0"/>
|
||||||
|
<rect x="67" y="257" width="30" height="16" rx="1" fill="#9170B8"/>
|
||||||
|
<g filter="url(#kibana_dashboard_light__filter1_d)">
|
||||||
|
<rect x="24" y="24" width="520" height="124" rx="4" fill="#fff"/>
|
||||||
|
</g>
|
||||||
|
<rect x="32" y="32" width="101" height="4" rx="2" fill="#6A717D"/>
|
||||||
|
<rect x="38" y="52" width="18" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="32" y="73" width="24" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="40" y="94" width="16" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="36" y="115" width="20" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="72" y="136" width="24" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="216" y="136" width="24" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="360" y="136" width="24" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="504" y="136" width="24" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<path opacity=".1" d="M80.177 118L65 110v17h470.5V90.5l-15.177 12.692-15.178 1.346-15.177-8.846-15.178 17.885-15.177.961-15.178-5.384-15.177-.769-15.177 5.384-15.178-5.846-15.177-40.615-15.178 18.077-15.177-38.462L338.194 104l-15.178-16.308-15.177 12.923-15.178-9.538-15.177 5.615-15.178-24.384L247.129 71l-15.177-15.615-15.178 49.077-15.177 4.615-15.178-22.154-15.177-5.385L156.065 50l-15.178 37.923-15.177 6.923-15.178 19.692-15.177-4.692L80.177 118z" fill="#54B399"/>
|
||||||
|
<path d="M65 110l15.177 8 15.178-8.154 15.177 4.692 15.178-19.692 15.177-6.923L156.065 50l15.177 31.538 15.177 5.385 15.178 22.154 15.177-4.615 15.178-49.077L247.129 71l15.177 1.308 15.178 24.384 15.177-5.615 15.178 9.538 15.177-12.923L338.194 104l15.177-57.077 15.177 38.462 15.178-18.077 15.177 40.615 15.178 5.846 15.177-5.384 15.177.769 15.178 5.384 15.177-.961 15.178-17.885 15.177 8.846 15.178-1.346L535.5 90.5" stroke="#54B399" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle opacity=".1" cx="100" cy="63" r="7.5" fill="#6092C0" stroke="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="232" cy="55" r="7.5" fill="#6092C0" stroke="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="323" cy="88" r="7.5" fill="#6092C0" stroke="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="353" cy="47" r="7.5" fill="#6092C0" stroke="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="523" cy="47" r="7.5" fill="#6092C0" stroke="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="106" cy="68" r="3.5" fill="#6092C0" stroke="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="156" cy="50" r="3.5" fill="#6092C0" stroke="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="240" cy="55" r="3.5" fill="#6092C0" stroke="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="262" cy="72" r="3.5" fill="#6092C0" stroke="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="345" cy="48" r="3.5" fill="#6092C0" stroke="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="484" cy="92" r="3.5" fill="#6092C0" stroke="#6092C0"/>
|
||||||
|
<circle opacity=".1" cx="490" cy="96" r="3.5" fill="#6092C0" stroke="#6092C0"/>
|
||||||
|
<circle cx="100" cy="63" r="7.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="232" cy="55" r="7.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="323" cy="88" r="7.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="353" cy="47" r="7.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="523" cy="47" r="7.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="106" cy="68" r="3.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="156" cy="50" r="3.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="240" cy="55" r="3.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="262" cy="72" r="3.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="345" cy="48" r="3.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="484" cy="92" r="3.5" stroke="#6092C0"/>
|
||||||
|
<circle cx="490" cy="96" r="3.5" stroke="#6092C0"/>
|
||||||
|
<path d="M64.5 44.5V54m471 73.5H516M64.5 54h-4m4 0v21m0 0h-4m4 0v21m0 0h-4m4 0v21m0 0v10.5H84M64.5 117h-4M84 131.5v-4m0 0h144m0 0v4m0-4h144m0 0v4m0-4h144m0 0v4" stroke="#D3DAE6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<g filter="url(#kibana_dashboard_light__filter2_d)">
|
||||||
|
<rect x="296" y="172" width="248" height="124" rx="4" fill="#fff"/>
|
||||||
|
</g>
|
||||||
|
<rect x="304" y="180" width="80" height="4" rx="2" fill="#6A717D"/>
|
||||||
|
<path d="M448.284 268.284a39.997 39.997 0 00-10.054-63.888 39.997 39.997 0 00-24.383-3.92l2.461 15.81a24 24 0 0120.663 40.685l11.313 11.313z" fill="#54B399"/>
|
||||||
|
<mask id="a" maskUnits="userSpaceOnUse" x="385" y="250" width="72" height="31" fill="#000">
|
||||||
|
<path fill="#fff" d="M385 250h72v31h-72z"/>
|
||||||
|
<path d="M386.416 261.728a39.996 39.996 0 0034.591 18.259 39.994 39.994 0 0033.628-19.976l-13.854-8.005a24 24 0 01-40.932 1.031l-13.433 8.691z"/>
|
||||||
|
</mask>
|
||||||
|
<path d="M386.416 261.728a39.996 39.996 0 0034.591 18.259 39.994 39.994 0 0033.628-19.976l-13.854-8.005a24 24 0 01-40.932 1.031l-13.433 8.691z" fill="#6092C0"/>
|
||||||
|
<path d="M386.416 261.728a39.996 39.996 0 0034.591 18.259 39.994 39.994 0 0033.628-19.976l-13.854-8.005a24 24 0 01-40.932 1.031l-13.433 8.691z" stroke="#fff" stroke-width="2" mask="url(#a)"/>
|
||||||
|
<mask id="b" maskUnits="userSpaceOnUse" x="379" y="213" width="24" height="54" fill="#000">
|
||||||
|
<path fill="#fff" d="M379 213h24v54h-24z"/>
|
||||||
|
<path d="M388.645 215.163a40 40 0 00.54 50.341l12.326-10.202a24 24 0 01-.324-30.204l-12.542-9.935z"/>
|
||||||
|
</mask>
|
||||||
|
<path d="M388.645 215.163a40 40 0 00.54 50.341l12.326-10.202a24 24 0 01-.324-30.204l-12.542-9.935z" fill="#D36086"/>
|
||||||
|
<path d="M388.645 215.163a40 40 0 00.54 50.341l12.326-10.202a24 24 0 01-.324-30.204l-12.542-9.935z" stroke="#fff" stroke-width="2" mask="url(#b)"/>
|
||||||
|
<mask id="c" maskUnits="userSpaceOnUse" x="384" y="199" width="37" height="30" fill="#000">
|
||||||
|
<path fill="#fff" d="M384 199h37v30h-37z"/>
|
||||||
|
<path d="M420 200a40.005 40.005 0 00-33.929 18.814l13.572 8.474A24.004 24.004 0 01420 216v-16z"/>
|
||||||
|
</mask>
|
||||||
|
<path d="M420 200a40.005 40.005 0 00-33.929 18.814l13.572 8.474A24.004 24.004 0 01420 216v-16z" fill="#9170B8"/>
|
||||||
|
<path d="M420 200a40.005 40.005 0 00-33.929 18.814l13.572 8.474A24.004 24.004 0 01420 216v-16z" stroke="#fff" stroke-width="2" mask="url(#c)"/>
|
||||||
|
<rect x="367" y="192" width="22" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="345" y="250" width="16" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="441" y="284" width="24" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<rect x="471" y="210" width="24" height="4" rx="2" fill="#98A2B3"/>
|
||||||
|
<path d="M393.5 194h4l4 8.5m65 9.5h-4l-7 4.5m-26.5 64l3.5 5.5h4m-58-41l-9 7h-4" stroke="#D3DAE6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="kibana_dashboard_light__filter0_d" x="8" y="160" width="280" height="156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||||
|
<feOffset dy="4"/>
|
||||||
|
<feGaussianBlur stdDeviation="8"/>
|
||||||
|
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
|
||||||
|
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||||
|
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="kibana_dashboard_light__filter1_d" x="8" y="12" width="552" height="156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||||
|
<feOffset dy="4"/>
|
||||||
|
<feGaussianBlur stdDeviation="8"/>
|
||||||
|
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
|
||||||
|
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||||
|
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="kibana_dashboard_light__filter2_d" x="280" y="160" width="280" height="156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||||
|
<feOffset dy="4"/>
|
||||||
|
<feGaussianBlur stdDeviation="8"/>
|
||||||
|
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
|
||||||
|
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||||
|
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 9.1 KiB |
After Width: | Height: | Size: 656 KiB |
After Width: | Height: | Size: 624 KiB |
After Width: | Height: | Size: 476 KiB |
After Width: | Height: | Size: 268 KiB |
|
@ -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<HTMLDivElement>;
|
||||||
|
|
||||||
|
export function SectionTitle({ children }: { children?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EuiText size={'s'} grow={false}>
|
||||||
|
<h5>{children}</h5>
|
||||||
|
</EuiText>
|
||||||
|
<EuiSpacer size={'xs'} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionSubtitle({ children }: { children?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EuiText size={'xs'} color={'subdued'} grow={false}>
|
||||||
|
<small>{children}</small>
|
||||||
|
</EuiText>
|
||||||
|
<EuiSpacer size={'s'} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionLinks({ children, ...props }: { children?: ReactNode } & EuiListGroupProps) {
|
||||||
|
return (
|
||||||
|
<EuiListGroup {...props} flush={true} bordered={false}>
|
||||||
|
{children}
|
||||||
|
</EuiListGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionSpacer() {
|
||||||
|
return <EuiSpacer size={'l'} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Section = styled.div`
|
||||||
|
margin-bottom: 16px;
|
||||||
|
&:last-of-type {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export type SectionLinkProps = EuiListGroupItemProps;
|
||||||
|
export function SectionLink(props: SectionLinkProps) {
|
||||||
|
return <EuiListGroupItem style={{ padding: 0 }} size={'xs'} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionMenuDivider() {
|
||||||
|
return <EuiHorizontalRule margin={'s'} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionMenu(props: Props) {
|
||||||
|
return <EuiPopover {...props} ownFocus={true} />;
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<EuiHeaderLink
|
||||||
|
aria-label={i18n.translate('xpack.exploratoryView.page_header.addMobileDataLink.label', {
|
||||||
|
defaultMessage: 'Navigate to a tutorial about adding mobile APM data',
|
||||||
|
})}
|
||||||
|
href={kibana.services?.application?.getUrlForApp('/home#/tutorial/apm')}
|
||||||
|
color="primary"
|
||||||
|
iconType="indexOpen"
|
||||||
|
>
|
||||||
|
{ADD_DATA_LABEL}
|
||||||
|
</EuiHeaderLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADD_DATA_LABEL = i18n.translate('xpack.exploratoryView.mobile.addDataButtonLabel', {
|
||||||
|
defaultMessage: 'Add Mobile data',
|
||||||
|
});
|
|
@ -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 (
|
||||||
|
<EuiHeaderLink
|
||||||
|
aria-label={i18n.translate('xpack.exploratoryView.page_header.addUptimeDataLink.label', {
|
||||||
|
defaultMessage: 'Navigate to a tutorial about adding Uptime data',
|
||||||
|
})}
|
||||||
|
href={kibana.services?.application?.getUrlForApp('/home#/tutorial/uptimeMonitors')}
|
||||||
|
color="primary"
|
||||||
|
iconType="indexOpen"
|
||||||
|
>
|
||||||
|
{ADD_DATA_LABEL}
|
||||||
|
</EuiHeaderLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADD_DATA_LABEL = i18n.translate('xpack.exploratoryView..synthetics.addDataButtonLabel', {
|
||||||
|
defaultMessage: 'Add synthetics data',
|
||||||
|
});
|
|
@ -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 (
|
||||||
|
<EuiHeaderLink
|
||||||
|
aria-label={i18n.translate('xpack.exploratoryView.page_header.addUXDataLink.label', {
|
||||||
|
defaultMessage: 'Navigate to a tutorial about adding user experience APM data',
|
||||||
|
})}
|
||||||
|
href={kibana.services?.application?.getUrlForApp('/home#/tutorial/apm')}
|
||||||
|
color="primary"
|
||||||
|
iconType="indexOpen"
|
||||||
|
>
|
||||||
|
{ADD_DATA_LABEL}
|
||||||
|
</EuiHeaderLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADD_DATA_LABEL = i18n.translate('xpack.exploratoryView.ux.addDataButtonLabel', {
|
||||||
|
defaultMessage: 'Add UX data',
|
||||||
|
});
|
|
@ -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<CoreStart>);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'app/RumDashboard/CoreVitalItem',
|
||||||
|
component: CoreVitalItem,
|
||||||
|
decorators: [
|
||||||
|
(Story: ComponentType) => (
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<KibanaReactContext.Provider>
|
||||||
|
<Story />
|
||||||
|
</KibanaReactContext.Provider>
|
||||||
|
</IntlProvider>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NoDataAvailable() {
|
||||||
|
return (
|
||||||
|
<CoreVitalItem
|
||||||
|
thresholds={{ good: '0.1', bad: '0.25' }}
|
||||||
|
title={LCP_LABEL}
|
||||||
|
value={null}
|
||||||
|
loading={false}
|
||||||
|
helpLabel={LCP_HELP_LABEL}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OneHundredPercentGood() {
|
||||||
|
return (
|
||||||
|
<CoreVitalItem
|
||||||
|
thresholds={{ good: '0.1', bad: '0.25' }}
|
||||||
|
title={LCP_LABEL}
|
||||||
|
value={'0.00s'}
|
||||||
|
loading={false}
|
||||||
|
ranks={[100, 0, 0]}
|
||||||
|
helpLabel={LCP_HELP_LABEL}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FiftyPercentGood() {
|
||||||
|
return (
|
||||||
|
<CoreVitalItem
|
||||||
|
thresholds={{ good: '0.1', bad: '0.25' }}
|
||||||
|
title={LCP_LABEL}
|
||||||
|
value={'0.00s'}
|
||||||
|
loading={false}
|
||||||
|
ranks={[50, 25, 25]}
|
||||||
|
helpLabel={LCP_HELP_LABEL}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OneHundredPercentBad() {
|
||||||
|
return (
|
||||||
|
<CoreVitalItem
|
||||||
|
thresholds={{ good: '0.1', bad: '0.25' }}
|
||||||
|
title={LCP_LABEL}
|
||||||
|
value={'0.00s'}
|
||||||
|
loading={false}
|
||||||
|
ranks={[0, 0, 100]}
|
||||||
|
helpLabel={LCP_HELP_LABEL}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OneHundredPercentAverage() {
|
||||||
|
return (
|
||||||
|
<CoreVitalItem
|
||||||
|
thresholds={{ good: '0.1', bad: '0.25' }}
|
||||||
|
title={LCP_LABEL}
|
||||||
|
value={'0.00s'}
|
||||||
|
loading={false}
|
||||||
|
ranks={[0, 100, 0]}
|
||||||
|
helpLabel={LCP_HELP_LABEL}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<EuiFlexItem key={hexCode} grow={false} style={{ width: percentage + '%' }}>
|
||||||
|
<EuiToolTip content={tooltip}>
|
||||||
|
<ColoredSpan style={spanStyle} />
|
||||||
|
</EuiToolTip>
|
||||||
|
</EuiFlexItem>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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(
|
||||||
|
<CoreVitalItem
|
||||||
|
title={title}
|
||||||
|
value={value}
|
||||||
|
ranks={[85, 10, 5]}
|
||||||
|
loading={loading}
|
||||||
|
thresholds={thresholds}
|
||||||
|
helpLabel={helpLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CoreVitalItem
|
||||||
|
title={title}
|
||||||
|
value={value}
|
||||||
|
ranks={[85, 10, 5]}
|
||||||
|
loading={true}
|
||||||
|
thresholds={thresholds}
|
||||||
|
helpLabel={helpLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(queryByText(value)).not.toBeInTheDocument();
|
||||||
|
expect(getByText('--')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no data UI if value is falsey and loading is falsey', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<CoreVitalItem
|
||||||
|
title={title}
|
||||||
|
value={null}
|
||||||
|
ranks={[85, 10, 5]}
|
||||||
|
loading={loading}
|
||||||
|
thresholds={thresholds}
|
||||||
|
helpLabel={helpLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText(NO_DATA)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<number | null>(null);
|
||||||
|
|
||||||
|
const biggestValIndex = ranks.indexOf(Math.max(...ranks));
|
||||||
|
|
||||||
|
if (!value && !loading) {
|
||||||
|
return <EuiCard title={title} isDisabled={true} description={NO_DATA} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EuiStat
|
||||||
|
aria-label={`${title} ${value}`} // aria-label is required when passing a component, instead of a string, as the description
|
||||||
|
titleSize="s"
|
||||||
|
title={value ?? ''}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{title}
|
||||||
|
<EuiIconTip content={helpLabel} type="questionInCircle" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
titleColor={palette[biggestValIndex]}
|
||||||
|
isLoading={loading}
|
||||||
|
/>
|
||||||
|
<EuiSpacer size="s" />
|
||||||
|
<EuiFlexGroup
|
||||||
|
gutterSize="none"
|
||||||
|
alignItems="flexStart"
|
||||||
|
style={{ maxWidth: 350 }}
|
||||||
|
responsive={false}
|
||||||
|
>
|
||||||
|
{palette.map((hexCode, ind) => (
|
||||||
|
<ColorPaletteFlexItem
|
||||||
|
hexCode={hexCode}
|
||||||
|
key={hexCode}
|
||||||
|
position={ind}
|
||||||
|
inFocus={inFocusInd !== ind && inFocusInd !== null}
|
||||||
|
percentage={ranks[ind]}
|
||||||
|
tooltip={getCoreVitalTooltipMessage(thresholds, ind, title, ranks[ind], isCls)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</EuiFlexGroup>
|
||||||
|
<EuiSpacer size="s" />
|
||||||
|
<PaletteLegends
|
||||||
|
ranks={ranks}
|
||||||
|
thresholds={thresholds}
|
||||||
|
title={title}
|
||||||
|
onItemHover={(ind) => {
|
||||||
|
setInFocusInd(ind);
|
||||||
|
}}
|
||||||
|
isCls={isCls}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<WebCoreVitalsTitle
|
||||||
|
loading={loading}
|
||||||
|
coreVitalPages={coreVitalPages}
|
||||||
|
totalPageViews={totalPageViews}
|
||||||
|
displayTrafficMetric={displayTrafficMetric}
|
||||||
|
/>
|
||||||
|
<EuiSpacer size="s" />
|
||||||
|
{displayServiceName && <ServiceName name={serviceName!} />}
|
||||||
|
<EuiSpacer size="s" />
|
||||||
|
<EuiFlexGroup gutterSize="xl" justifyContent={'spaceBetween'} wrap>
|
||||||
|
<EuiFlexItem style={{ flexBasis: 380 }}>
|
||||||
|
<CoreVitalItem
|
||||||
|
title={LCP_LABEL}
|
||||||
|
value={formatToMilliseconds(lcp)}
|
||||||
|
ranks={lcpRanks}
|
||||||
|
loading={loading}
|
||||||
|
thresholds={CoreVitalsThresholds.LCP}
|
||||||
|
helpLabel={LCP_HELP_LABEL}
|
||||||
|
/>
|
||||||
|
</EuiFlexItem>
|
||||||
|
<EuiFlexItem style={{ flexBasis: 380 }}>
|
||||||
|
<CoreVitalItem
|
||||||
|
title={FID_LABEL}
|
||||||
|
value={formatToMilliseconds(fid)}
|
||||||
|
ranks={fidRanks}
|
||||||
|
loading={loading}
|
||||||
|
thresholds={CoreVitalsThresholds.FID}
|
||||||
|
helpLabel={FID_HELP_LABEL}
|
||||||
|
/>
|
||||||
|
</EuiFlexItem>
|
||||||
|
<EuiFlexItem style={{ flexBasis: 380 }}>
|
||||||
|
<CoreVitalItem
|
||||||
|
title={CLS_LABEL}
|
||||||
|
value={cls?.toFixed(3) ?? null}
|
||||||
|
ranks={clsRanks}
|
||||||
|
loading={loading}
|
||||||
|
thresholds={CoreVitalsThresholds.CLS}
|
||||||
|
isCls={true}
|
||||||
|
helpLabel={CLS_HELP_LABEL}
|
||||||
|
/>
|
||||||
|
</EuiFlexItem>
|
||||||
|
</EuiFlexGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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$<boolean>('theme:darkMode');
|
||||||
|
|
||||||
|
const palette = euiPaletteForStatus(3);
|
||||||
|
const labels = [LEGEND_GOOD_LABEL, LEGEND_NEEDS_IMPROVEMENT_LABEL, LEGEND_POOR_LABEL];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EuiFlexGroup responsive={false} gutterSize="s">
|
||||||
|
{palette.map((color, ind) => (
|
||||||
|
<EuiFlexItem
|
||||||
|
key={ind}
|
||||||
|
grow={false}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
onItemHover(ind);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
onItemHover(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EuiToolTip
|
||||||
|
content={getCoreVitalTooltipMessage(thresholds, ind, title, ranks[ind], isCls)}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<StyledSpan darkMode={darkMode}>
|
||||||
|
<PaletteLegend color={color}>
|
||||||
|
<EuiText size="xs" data-test-subj={`${labels[ind]}-${ranks?.[ind]}`}>
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.exploratoryView.ux.coreVitals.paletteLegend.rankPercentage"
|
||||||
|
defaultMessage="{labelsInd} ({ranksInd}%)"
|
||||||
|
values={{
|
||||||
|
labelsInd: labels[ind],
|
||||||
|
ranksInd: ranks?.[ind],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EuiText>
|
||||||
|
</PaletteLegend>
|
||||||
|
</StyledSpan>
|
||||||
|
</EuiToolTip>
|
||||||
|
</EuiFlexItem>
|
||||||
|
))}
|
||||||
|
</EuiFlexGroup>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<EuiText size="s">
|
||||||
|
{SERVICE_LABEL}
|
||||||
|
<EuiIconTip
|
||||||
|
color="text"
|
||||||
|
aria-label={SERVICE_LABEL_HELP}
|
||||||
|
type="questionInCircle"
|
||||||
|
content={SERVICE_LABEL_HELP}
|
||||||
|
/>
|
||||||
|
</EuiText>
|
||||||
|
<EuiTitle size="s">
|
||||||
|
<h3>{name}</h3>
|
||||||
|
</EuiTitle>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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.',
|
||||||
|
});
|
|
@ -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 (
|
||||||
|
<EuiFlexGroup gutterSize="none">
|
||||||
|
<EuiFlexItem>
|
||||||
|
<EuiTitle size="xs">
|
||||||
|
<h3>
|
||||||
|
{CORE_WEB_VITALS}
|
||||||
|
<EuiPopover
|
||||||
|
isOpen={isPopoverOpen}
|
||||||
|
button={
|
||||||
|
<EuiButtonIcon
|
||||||
|
aria-label={helpAriaLabel}
|
||||||
|
onClick={() => setIsPopoverOpen(true)}
|
||||||
|
color={'text'}
|
||||||
|
iconType={'questionInCircle'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
closePopover={closePopover}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<EuiText>
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.exploratoryView.ux.dashboard.webCoreVitals.help"
|
||||||
|
defaultMessage="Learn more about"
|
||||||
|
/>{' '}
|
||||||
|
<EuiLink
|
||||||
|
data-test-subj="o11yWebCoreVitalsTitleLink"
|
||||||
|
href="https://web.dev/vitals/"
|
||||||
|
external
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{CORE_WEB_VITALS}
|
||||||
|
</EuiLink>
|
||||||
|
</EuiText>
|
||||||
|
</div>
|
||||||
|
</EuiPopover>
|
||||||
|
</h3>
|
||||||
|
</EuiTitle>
|
||||||
|
</EuiFlexItem>
|
||||||
|
{displayTrafficMetric && totalPageViews > 0 && (
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
{loading ? (
|
||||||
|
<EuiLoadingSpinner />
|
||||||
|
) : (
|
||||||
|
<EuiText size="s">
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.exploratoryView.ux.dashboard.webCoreVitals.traffic"
|
||||||
|
defaultMessage="{trafficPerc} of the traffic represented"
|
||||||
|
values={{
|
||||||
|
trafficPerc: (
|
||||||
|
<strong> {(((coreVitalPages || 0) / totalPageViews) * 100).toFixed(0)}%</strong>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EuiPopover
|
||||||
|
isOpen={isBrowserPopoverOpen}
|
||||||
|
button={
|
||||||
|
<EuiButtonIcon
|
||||||
|
aria-label={helpAriaLabel}
|
||||||
|
onClick={() => setIsBrowserPopoverOpen(true)}
|
||||||
|
color={'text'}
|
||||||
|
iconType={'questionInCircle'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
closePopover={closeBrowserPopover}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<EuiText>
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.exploratoryView.ux.dashboard.webCoreVitals.browser.help"
|
||||||
|
defaultMessage="Learn more about"
|
||||||
|
/>{' '}
|
||||||
|
<EuiLink
|
||||||
|
data-test-subj="o11yWebCoreVitalsTitleLink"
|
||||||
|
href="https://github.com/GoogleChrome/web-vitals#browser-support"
|
||||||
|
external
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{BROWSER_CORE_WEB_VITALS}
|
||||||
|
</EuiLink>
|
||||||
|
</EuiText>
|
||||||
|
</div>
|
||||||
|
</EuiPopover>
|
||||||
|
</EuiText>
|
||||||
|
)}
|
||||||
|
</EuiFlexItem>
|
||||||
|
)}
|
||||||
|
</EuiFlexGroup>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<DatePicker
|
||||||
|
rangeFrom={rangeFrom}
|
||||||
|
rangeTo={rangeTo}
|
||||||
|
refreshInterval={refreshInterval}
|
||||||
|
refreshPaused={refreshPaused}
|
||||||
|
onTimeRangeRefresh={mockRefreshTimeRange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
<Router history={history}>
|
||||||
|
<KibanaContextProvider
|
||||||
|
services={{
|
||||||
|
data: {
|
||||||
|
query: {
|
||||||
|
timefilter: {
|
||||||
|
timefilter: {
|
||||||
|
setTime: setTimeSpy,
|
||||||
|
getTime: getTimeSpy,
|
||||||
|
getTimeDefaults: jest.fn().mockReturnValue({}),
|
||||||
|
getRefreshIntervalDefaults: jest.fn().mockReturnValue({}),
|
||||||
|
getRefreshInterval: jest.fn().mockReturnValue({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
uiSettings: {
|
||||||
|
get: (key: string) => [],
|
||||||
|
get$: (key: string) => of(true),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatePickerContextProvider>
|
||||||
|
<DatePickerWrapper />
|
||||||
|
</DatePickerContextProvider>
|
||||||
|
</KibanaContextProvider>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<TimePickerQuickRange[]>(
|
||||||
|
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 (
|
||||||
|
<EuiSuperDatePicker
|
||||||
|
start={rangeFrom}
|
||||||
|
end={rangeTo}
|
||||||
|
onTimeChange={onRefresh}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
isPaused={refreshPaused}
|
||||||
|
refreshInterval={refreshInterval}
|
||||||
|
onRefreshChange={onRefreshChange}
|
||||||
|
commonlyUsedRanges={commonlyUsedRanges}
|
||||||
|
width={width}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default DatePicker;
|
|
@ -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;
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## 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;
|
||||||
|
`
|
||||||
|
|
||||||
|
```
|
||||||
|
<ExploratoryViewComponent
|
||||||
|
attributes={[
|
||||||
|
{
|
||||||
|
name: 'Monitors response duration',
|
||||||
|
time: {
|
||||||
|
from: 'now-5d',
|
||||||
|
to: 'now',
|
||||||
|
},
|
||||||
|
reportDefinitions: {
|
||||||
|
'monitor.id': ['test-id'],
|
||||||
|
},
|
||||||
|
breakdown: 'monitor.type',
|
||||||
|
operationType: 'average',
|
||||||
|
dataType: 'synthetics',
|
||||||
|
seriesType: 'line',
|
||||||
|
selectedMetricField: 'monitor.duration.us',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
reportType="kpi-over-time"
|
||||||
|
title={'Monitor response duration'}
|
||||||
|
withActions={['save', 'explore']}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
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)
|
|
@ -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(
|
||||||
|
<ExpViewActionMenuContent
|
||||||
|
lensAttributes={sampleAttribute as TypedLensByValueInput['attributes']}
|
||||||
|
timeRange={{ to: 'now', from: 'now-10m' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ExpViewActionMenuContent
|
||||||
|
lensAttributes={sampleAttribute as TypedLensByValueInput['attributes']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Save')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(await findByText('Save'));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Lens Save Modal Component')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<ObservabilityAppServices>().services;
|
||||||
|
|
||||||
|
const { lens, isDev } = kServices;
|
||||||
|
|
||||||
|
const [isSaveOpen, setIsSaveOpen] = useState(false);
|
||||||
|
|
||||||
|
const LensSaveModalComponent = lens.SaveModalComponent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EuiFlexGroup
|
||||||
|
alignItems="center"
|
||||||
|
gutterSize="s"
|
||||||
|
responsive={false}
|
||||||
|
style={{ paddingRight: 20 }}
|
||||||
|
>
|
||||||
|
{isDev && (
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<EmbedAction lensAttributes={lensAttributes} />
|
||||||
|
</EuiFlexItem>
|
||||||
|
)}
|
||||||
|
{timeRange && (
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<AddToCaseAction lensAttributes={lensAttributes} timeRange={timeRange} />
|
||||||
|
</EuiFlexItem>
|
||||||
|
)}
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<EuiButton
|
||||||
|
data-test-subj="o11yExpViewActionMenuContentOpenInLensButton"
|
||||||
|
iconType="lensApp"
|
||||||
|
fullWidth={false}
|
||||||
|
isDisabled={!lens.canUseEditor() || lensAttributes === null}
|
||||||
|
size="s"
|
||||||
|
onClick={() => {
|
||||||
|
if (lensAttributes) {
|
||||||
|
lens.navigateToPrefilledEditor(
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
timeRange,
|
||||||
|
attributes: lensAttributes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
openInNewTab: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n.translate('xpack.exploratoryView.expView.heading.openInLens', {
|
||||||
|
defaultMessage: 'Open in Lens',
|
||||||
|
})}
|
||||||
|
</EuiButton>
|
||||||
|
</EuiFlexItem>
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<EuiButton
|
||||||
|
data-test-subj="o11yExpViewActionMenuContentSaveButton"
|
||||||
|
fill={true}
|
||||||
|
iconType="save"
|
||||||
|
fullWidth={false}
|
||||||
|
isDisabled={!lens.canUseEditor() || lensAttributes === null}
|
||||||
|
onClick={() => {
|
||||||
|
if (lensAttributes) {
|
||||||
|
setIsSaveOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="s"
|
||||||
|
>
|
||||||
|
{i18n.translate('xpack.exploratoryView.expView.heading.saveLensVisualization', {
|
||||||
|
defaultMessage: 'Save',
|
||||||
|
})}
|
||||||
|
</EuiButton>
|
||||||
|
</EuiFlexItem>
|
||||||
|
</EuiFlexGroup>
|
||||||
|
|
||||||
|
{isSaveOpen && lensAttributes && (
|
||||||
|
<LensSaveModalComponent
|
||||||
|
initialInput={lensAttributes as unknown as LensEmbeddableInput}
|
||||||
|
onClose={() => setIsSaveOpen(false)}
|
||||||
|
// if we want to do anything after the viz is saved
|
||||||
|
// right now there is no action, so an empty function
|
||||||
|
onSave={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<HeaderMenuPortal setHeaderActionMenu={setHeaderActionMenu} theme$={theme$}>
|
||||||
|
<ExpViewActionMenuContent {...props} />
|
||||||
|
</HeaderMenuPortal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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<string>('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 (
|
||||||
|
<EuiDatePickerRange
|
||||||
|
fullWidth
|
||||||
|
isCustom
|
||||||
|
startDateControl={
|
||||||
|
<EuiDatePicker
|
||||||
|
fullWidth
|
||||||
|
selected={startDate}
|
||||||
|
onChange={onStartChange}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
isInvalid={startDate > endDate}
|
||||||
|
aria-label={i18n.translate('xpack.exploratoryView.expView.dateRanger.startDate', {
|
||||||
|
defaultMessage: 'Start date',
|
||||||
|
})}
|
||||||
|
dateFormat={dateFormat.replace('ss.SSS', 'ss')}
|
||||||
|
showTimeSelect
|
||||||
|
popoverPlacement="rightCenter"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
endDateControl={
|
||||||
|
<EuiDatePicker
|
||||||
|
fullWidth
|
||||||
|
showIcon={false}
|
||||||
|
selected={endDate}
|
||||||
|
onChange={onEndChange}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
isInvalid={startDate > endDate}
|
||||||
|
aria-label={i18n.translate('xpack.exploratoryView.expView.dateRanger.endDate', {
|
||||||
|
defaultMessage: 'End date',
|
||||||
|
})}
|
||||||
|
dateFormat={dateFormat.replace('ss.SSS', 'ss')}
|
||||||
|
showTimeSelect
|
||||||
|
popoverPlacement="rightCenter"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<Wrapper>
|
||||||
|
{loading && (
|
||||||
|
<EuiProgress
|
||||||
|
size="xs"
|
||||||
|
color="accent"
|
||||||
|
position="absolute"
|
||||||
|
style={{
|
||||||
|
top: 'initial',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<EuiSpacer />
|
||||||
|
<FlexGroup justifyContent="center" alignItems="center">
|
||||||
|
<EuiFlexItem>
|
||||||
|
<EuiText>{loading ? LOADING_VIEW : emptyMessage}</EuiText>
|
||||||
|
</EuiFlexItem>
|
||||||
|
</FlexGroup>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.' }
|
||||||
|
);
|
|
@ -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(
|
||||||
|
<FilterLabel
|
||||||
|
field={'service.name'}
|
||||||
|
value={'elastic-co'}
|
||||||
|
label={'Web Application'}
|
||||||
|
negate={false}
|
||||||
|
seriesId={0}
|
||||||
|
removeFilter={jest.fn()}
|
||||||
|
dataView={mockDataView}
|
||||||
|
series={mockUxSeries}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<FilterLabel
|
||||||
|
field={'service.name'}
|
||||||
|
value={'elastic-co'}
|
||||||
|
label={'Web Application'}
|
||||||
|
negate={false}
|
||||||
|
seriesId={0}
|
||||||
|
removeFilter={removeFilter}
|
||||||
|
dataView={mockDataView}
|
||||||
|
series={mockUxSeries}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<FilterLabel
|
||||||
|
field={'service.name'}
|
||||||
|
value={'elastic-co'}
|
||||||
|
label={'Web Application'}
|
||||||
|
negate={false}
|
||||||
|
seriesId={0}
|
||||||
|
removeFilter={removeFilter}
|
||||||
|
dataView={mockDataView}
|
||||||
|
series={mockUxSeries}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<FilterLabel
|
||||||
|
field={'service.name'}
|
||||||
|
value={'elastic-co'}
|
||||||
|
label={'Web Application'}
|
||||||
|
negate={true}
|
||||||
|
seriesId={0}
|
||||||
|
removeFilter={jest.fn()}
|
||||||
|
dataView={mockDataView}
|
||||||
|
series={mockUxSeries}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<string | number>;
|
||||||
|
seriesId: number;
|
||||||
|
series: SeriesUrl;
|
||||||
|
negate: boolean;
|
||||||
|
definitionFilter?: boolean;
|
||||||
|
dataView: DataView;
|
||||||
|
removeFilter: (field: string, value: string | Array<string | number>, notVal: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterLabel({
|
||||||
|
label,
|
||||||
|
seriesId,
|
||||||
|
series,
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
negate,
|
||||||
|
dataView,
|
||||||
|
removeFilter,
|
||||||
|
definitionFilter,
|
||||||
|
}: Props) {
|
||||||
|
const { invertFilter } = useSeriesFilters({ seriesId, series });
|
||||||
|
|
||||||
|
return dataView ? (
|
||||||
|
<FilterValueLabel
|
||||||
|
dataView={dataView}
|
||||||
|
removeFilter={removeFilter}
|
||||||
|
invertFilter={(val) => {
|
||||||
|
if (!definitionFilter) invertFilter(val);
|
||||||
|
}}
|
||||||
|
field={field}
|
||||||
|
value={value}
|
||||||
|
negate={negate}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
}
|
|
@ -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<string, string>)[`euiColorVis${seriesId}`];
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<EuiToolTip content={EDIT_SERIES_COLOR_LABEL}>
|
||||||
|
<EuiButtonEmpty
|
||||||
|
data-test-subj="o11ySeriesColorPickerButton"
|
||||||
|
size="s"
|
||||||
|
onClick={() => setIsOpen((prevState) => !prevState)}
|
||||||
|
flush="both"
|
||||||
|
>
|
||||||
|
<EuiIcon type="stopFilled" size="l" color={color} />
|
||||||
|
</EuiButtonEmpty>
|
||||||
|
</EuiToolTip>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EuiPopover button={button} isOpen={isOpen} closePopover={() => setIsOpen(false)}>
|
||||||
|
<EuiFormRow label={PICK_A_COLOR_LABEL}>
|
||||||
|
<EuiColorPicker onChange={onChange} color={color} />
|
||||||
|
</EuiFormRow>
|
||||||
|
</EuiPopover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
);
|
|
@ -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 (
|
||||||
|
<EuiSuperDatePicker
|
||||||
|
start={series?.time?.from}
|
||||||
|
end={series?.time?.to}
|
||||||
|
onTimeChange={onTimeChange}
|
||||||
|
commonlyUsedRanges={commonlyUsedRanges}
|
||||||
|
onRefresh={onTimeChange}
|
||||||
|
showUpdateButton={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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(<SeriesDatePicker seriesId={0} series={initSeries.data[0]} />, {
|
||||||
|
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(
|
||||||
|
<SeriesDatePicker seriesId={0} series={initSeries.data[0]} />,
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|