[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/ux/e2e/synthetics_run.ts
|
||||
- x-pack/plugins/observability/e2e/synthetics_run.ts
|
||||
- x-pack/plugins/exploratory_view/e2e/synthetics_run.ts
|
||||
|
||||
# Configs that exist but weren't running in CI when this file was introduced
|
||||
- x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/config.ts
|
||||
|
|
|
@ -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'));
|
||||
}
|
||||
|
||||
if (await doAnyChangesMatch([/^x-pack\/plugins\/exploratory_view/])) {
|
||||
pipeline.push(getPipeline('.buildkite/pipelines/pull_request/exploratory_view_plugin.yml'));
|
||||
}
|
||||
|
||||
if (
|
||||
await doAnyChangesMatch([
|
||||
/^x-pack\/plugins\/synthetics/,
|
||||
/^x-pack\/plugins\/observability\/public\/components\/shared\/exploratory_view/,
|
||||
/^x-pack\/plugins\/exploratory_view/,
|
||||
])
|
||||
) {
|
||||
pipeline.push(getPipeline('.buildkite/pipelines/pull_request/synthetics_plugin.yml'));
|
||||
|
@ -154,6 +159,7 @@ const uploadPipeline = (pipelineContent: string | object) => {
|
|||
await doAnyChangesMatch([
|
||||
/^x-pack\/plugins\/ux/,
|
||||
/^x-pack\/plugins\/observability\/public\/components\/shared\/exploratory_view/,
|
||||
/^x-pack\/plugins\/exploratory_view/,
|
||||
])
|
||||
) {
|
||||
pipeline.push(getPipeline('.buildkite/pipelines/pull_request/ux_plugin_e2e.yml'));
|
||||
|
|
|
@ -867,6 +867,7 @@ module.exports = {
|
|||
files: [
|
||||
'x-pack/plugins/apm/**/*.{js,mjs,ts,tsx}',
|
||||
'x-pack/plugins/observability/**/*.{js,mjs,ts,tsx}',
|
||||
'x-pack/plugins/exploratory_view/**/*.{js,mjs,ts,tsx}',
|
||||
'x-pack/plugins/ux/**/*.{js,mjs,ts,tsx}',
|
||||
],
|
||||
rules: {
|
||||
|
@ -886,7 +887,11 @@ module.exports = {
|
|||
},
|
||||
},
|
||||
{
|
||||
files: ['x-pack/plugins/apm/**/*.stories.*', 'x-pack/plugins/observability/**/*.stories.*'],
|
||||
files: [
|
||||
'x-pack/plugins/apm/**/*.stories.*',
|
||||
'x-pack/plugins/observability/**/*.stories.*',
|
||||
'x-pack/plugins/exploratory_view/**/*.stories.*',
|
||||
],
|
||||
rules: {
|
||||
'react/function-component-definition': [
|
||||
'off',
|
||||
|
@ -901,6 +906,7 @@ module.exports = {
|
|||
files: [
|
||||
'x-pack/plugins/apm/**/*.{js,mjs,ts,tsx}',
|
||||
'x-pack/plugins/observability/**/*.{js,mjs,ts,tsx}',
|
||||
'x-pack/plugins/exploratory_view/**/*.{js,mjs,ts,tsx}',
|
||||
'x-pack/plugins/ux/**/*.{js,mjs,ts,tsx}',
|
||||
'x-pack/plugins/synthetics/**/*.{js,mjs,ts,tsx}',
|
||||
'x-pack/plugins/infra/**/*.{js,mjs,ts,tsx}',
|
||||
|
|
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-expect @elastic/kibana-operations
|
||||
x-pack/examples/exploratory_view_example @elastic/uptime
|
||||
x-pack/plugins/exploratory_view @elastic/uptime
|
||||
src/plugins/expression_error @elastic/kibana-presentation
|
||||
src/plugins/chart_expressions/expression_gauge @elastic/kibana-visualizations
|
||||
src/plugins/chart_expressions/expression_heatmap @elastic/kibana-visualizations
|
||||
|
|
|
@ -531,6 +531,10 @@ security and spaces filtering.
|
|||
activities.
|
||||
|
||||
|
||||
|{kib-repo}blob/{branch}/x-pack/plugins/exploratory_view/README.md[exploratoryView]
|
||||
|A shared component for visualizing observability data types via lens embeddable. For further details.
|
||||
|
||||
|
||||
|{kib-repo}blob/{branch}/x-pack/plugins/features/README.md[features]
|
||||
|The features plugin enhance Kibana with a per-feature privilege system.
|
||||
|
||||
|
|
|
@ -375,6 +375,7 @@
|
|||
"@kbn/event-log-plugin": "link:x-pack/plugins/event_log",
|
||||
"@kbn/expandable-flyout": "link:packages/kbn-expandable-flyout",
|
||||
"@kbn/exploratory-view-example-plugin": "link:x-pack/examples/exploratory_view_example",
|
||||
"@kbn/exploratory-view-plugin": "link:x-pack/plugins/exploratory_view",
|
||||
"@kbn/expression-error-plugin": "link:src/plugins/expression_error",
|
||||
"@kbn/expression-gauge-plugin": "link:src/plugins/chart_expressions/expression_gauge",
|
||||
"@kbn/expression-heatmap-plugin": "link:src/plugins/chart_expressions/expression_heatmap",
|
||||
|
|
|
@ -14,7 +14,7 @@ module.exports = {
|
|||
USES_STYLED_COMPONENTS: [
|
||||
/packages[\/\\](kbn-ui-shared-deps-(npm|src)|kbn-ecs-data-quality-dashboard)[\/\\]/,
|
||||
/src[\/\\]plugins[\/\\](kibana_react)[\/\\]/,
|
||||
/x-pack[\/\\]plugins[\/\\](apm|beats_management|cases|fleet|infra|lists|observability|osquery|security_solution|timelines|synthetics|ux)[\/\\]/,
|
||||
/x-pack[\/\\]plugins[\/\\](apm|beats_management|cases|fleet|infra|lists|observability|exploratory_view|osquery|security_solution|timelines|synthetics|ux)[\/\\]/,
|
||||
/x-pack[\/\\]test[\/\\]plugin_functional[\/\\]plugins[\/\\]resolver_test[\/\\]/,
|
||||
],
|
||||
};
|
||||
|
|
|
@ -40,6 +40,7 @@ pageLoadAssetSize:
|
|||
enterpriseSearch: 35741
|
||||
esUiShared: 326654
|
||||
eventAnnotation: 20500
|
||||
exploratoryView: 74673
|
||||
expressionError: 22127
|
||||
expressionGauge: 25000
|
||||
expressionHeatmap: 27505
|
||||
|
|
|
@ -22,6 +22,7 @@ export const TS_PROJECTS = TsProject.loadAll({
|
|||
'x-pack/plugins/synthetics/e2e/tsconfig.json',
|
||||
'x-pack/plugins/ux/e2e/tsconfig.json',
|
||||
'x-pack/plugins/observability/e2e/tsconfig.json',
|
||||
'x-pack/plugins/exploratory_view/e2e/tsconfig.json',
|
||||
'x-pack/plugins/threat_intelligence/cypress/tsconfig.json',
|
||||
],
|
||||
});
|
||||
|
|
|
@ -152,6 +152,7 @@ export const applicationUsageSchema = {
|
|||
ml: commonSchema,
|
||||
monitoring: commonSchema,
|
||||
'observability-overview': commonSchema,
|
||||
'exploratory-view': commonSchema,
|
||||
osquery: commonSchema,
|
||||
profiling: 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": {
|
||||
"properties": {
|
||||
"appId": {
|
||||
|
|
|
@ -682,6 +682,8 @@
|
|||
"@kbn/expect/*": ["packages/kbn-expect/*"],
|
||||
"@kbn/exploratory-view-example-plugin": ["x-pack/examples/exploratory_view_example"],
|
||||
"@kbn/exploratory-view-example-plugin/*": ["x-pack/examples/exploratory_view_example/*"],
|
||||
"@kbn/exploratory-view-plugin": ["x-pack/plugins/exploratory_view"],
|
||||
"@kbn/exploratory-view-plugin/*": ["x-pack/plugins/exploratory_view/*"],
|
||||
"@kbn/expression-error-plugin": ["src/plugins/expression_error"],
|
||||
"@kbn/expression-error-plugin/*": ["src/plugins/expression_error/*"],
|
||||
"@kbn/expression-gauge-plugin": ["src/plugins/chart_expressions/expression_gauge"],
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
"xpack.enterpriseSearch": "plugins/enterprise_search",
|
||||
"xpack.features": "plugins/features",
|
||||
"xpack.dataVisualizer": "plugins/data_visualizer",
|
||||
"xpack.exploratoryView": "plugins/exploratory_view",
|
||||
"xpack.fileUpload": "plugins/file_upload",
|
||||
"xpack.globalSearch": ["plugins/global_search"],
|
||||
"xpack.globalSearchBar": ["plugins/global_search_bar"],
|
||||
|
|
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);
|
||||
});
|
||||
});
|