[Observability] Copy Exploratory View into a separate app (#153852)

This commit is contained in:
Coen Warmer 2023-03-29 10:30:58 +02:00 committed by GitHub
parent c48098d8da
commit 6b6a8dfecb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
347 changed files with 29163 additions and 6 deletions

View file

@ -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

View file

@ -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

View file

@ -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'));

View file

@ -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
View file

@ -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

View file

@ -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.

View file

@ -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",

View file

@ -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[\/\\]/,
],
};

View file

@ -40,6 +40,7 @@ pageLoadAssetSize:
enterpriseSearch: 35741
esUiShared: 326654
eventAnnotation: 20500
exploratoryView: 74673
expressionError: 22127
expressionGauge: 25000
expressionHeatmap: 27505

View file

@ -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',
],
});

View file

@ -152,6 +152,7 @@ export const applicationUsageSchema = {
ml: commonSchema,
monitoring: commonSchema,
'observability-overview': commonSchema,
'exploratory-view': commonSchema,
osquery: commonSchema,
profiling: commonSchema,
security_account: commonSchema,

View file

@ -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": {

View file

@ -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"],

View file

@ -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"],

View 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);

View 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;

View 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];

View 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
```

View 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;
}

View 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';

View 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 { DOCUMENT_FIELD_NAME } from '@kbn/lens-plugin/common';
export const LOG_RATE = DOCUMENT_FIELD_NAME;

View 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.
*/
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';

View file

@ -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,
];

View 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',
});

View 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';

View 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',
}

View file

@ -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;
}
}

View 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;
}

View 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';

View file

@ -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;
};

View file

@ -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;
}

View file

@ -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
);
});
});
});

View file

@ -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}`;
}

View file

@ -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');
});
});
});

View file

@ -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}`;
}
};

View file

@ -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);
}
);
});
});

View 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 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);
}

View 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.
*/
export * from './formatters';
export * from './datetime';
export * from './duration';
export * from './size';

View file

@ -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('');
});
});
});
});

View 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 { 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;
};

View file

@ -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,
};
}

View 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.
*/
import { isFinite } from 'lodash';
// _.isNumber() returns true for NaN, _.isFinite() does not refine
export function isFiniteNumber(value: any): value is number {
return isFinite(value);
}

View file

@ -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: '',
},
});
});
});

View file

@ -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;
}, []);
}

View 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;
}

View 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>;
}

View file

@ -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);
});
}

View 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.

View 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"]');
});
});

View 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';

View file

@ -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'
);
});
});

View file

@ -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');
});
});

View 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 };

View 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);
}
});
};

View 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;

View 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');
}
}

View 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' }
);
};

View 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;
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../../../tsconfig.base.json",
"exclude": ["tmp", "target/**/*"],
"include": ["./**/*"],
"compilerOptions": {
"outDir": "target/types",
"types": [ "node"],
},
}

View 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,
};

View 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}',
],
};

View 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"]
}
}

View file

@ -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();
});
});

View 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);
};
};

View 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;
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 149 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 150 KiB

View file

@ -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

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

View file

@ -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} />;
}

View 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 { 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',
});

View 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 { 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',
});

View 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 { 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',
});

View file

@ -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}
/>
);
}

View file

@ -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>
);
}

View 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.
*/
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();
});
});

View file

@ -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}
/>
</>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View file

@ -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.',
});

View file

@ -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>
);
}

View file

@ -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);
});
});
});

View file

@ -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;

View file

@ -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;
}

View file

@ -0,0 +1,161 @@
# Exploratory view component
This component is used in observability plugin to show lens embeddable based observability visualizations.
The view is populated using configs stored as json within the view for each data type.
This readme file contains few of the concepts being used in the component.
Basic workflow for how exploratory view works, it looks like this
![Exploratory view workflow](https://i.imgur.com/Kgyfd29.png)
## Report Type
The exploratory view report type controls how the data is visualized in the lens embeddable. The report type defines a set of constraints over the x and y axis. For example, the `kpi-over-time` report type is a time series chart type that plots key performance indicators over time, while the `data-distribution` chart plots the percentage of documents over key performance indicators. Current available data types can be found at `exploratory_view/configurations/constants`.
Each report type has one or more available visualizations to plot data from one or more data types.
## Data Types
Each available visualization is backed by a data type. A data type consists of a set of configuration for displaying domain-specific visualizations for observability data. Some example data types include apm, metrics, and logs.
For each respective data type, we fetch index pattern string from the app plugin contract, leveraging existing hasData API we have to return the index pattern string as well as a `hasData` boolean from each plugin.
In most cases, there will be a 1-1 relation between apps and data types.
### Observability `dataViews`
Once we have index pattern string for each data type, a respective `dataView` is created. If there is an existing dataView for an index pattern, we will fetch and reuse it.
After the dataView is created we also set field formats to promote human-readability. For example, we set format for monitor duration field, which is monitor.duration.us, from microseconds to seconds for browser monitors.
### Visualization Configuration
Each data type may have one or more visualization configurations. The data type to visualization configuration can be found in [`exploratory_view/obs_exploratory_view`](https://github.com/elastic/kibana/blob/main/x-pack/plugins/observability/public/components/shared/exploratory_view/obsv_exploratory_view.tsx#L86)
Each visualization configuration is mapped to a single report type.
Visualization configurations are used to define the UI we display for each report type and data type combination in the series builder.
Visualization configuration define UI options and display, including available `metrics`, available `filters`, available `breakdown` options, definitions for human-readable `labels`, and more.
The configuration also defines any custom base filters, which usually get pushed to a query, but are not displayed on the UI. You can also set more custom options on the configuration like colors which get used while rendering the chart.
Visualization configuration can be found at [`exploratory_view/configurations`](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations), where each data type typically has a folder that holds one or more visualization configurations.
The configuration defined ultimately influences the lens embeddable attributes which get pushed to lens embeddable, rendering the chart.
Some options in configuration are:
#### Definition fields
They are also filters, but usually main filters, around which usually app UI is based.
For apm, it could be service name and for uptime, monitor name.
#### Filters
You can define base filters in kql form or data plugin filter format, filters are strongly typed.
#### Breakdown fields
List of fields from an index pattern, UI will use this to populate breakdown option select.
#### Labels
You can set key/value map for your field labels. UI will use these to set labels for data view fields.
Sample config
```
{
reportType: ReportTypes.KPI,
defaultSeriesType: 'bar_stacked',
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumns: [
{
sourceField: REPORT_METRIC_FIELD,
operationType: 'median',
},
],
hasOperationType: false,
filterFields: ['observer.geo.name', 'monitor.type', 'tags'], // these fields get's resolved from relevant dataView
breakdownFields: [
'observer.geo.name',
'monitor.type',
'monitor.name',
PERCENTILE,
], // these fields get's resolved from relevant dataView
baseFilters: [],
palette: { type: 'palette', name: 'status' },
definitionFields: [
{ field: 'monitor.name', nested: SYNTHETICS_STEP_NAME, singleSelection: true },
{ field: 'url.full', filters: buildExistsFilter('summary.up', dataView) },
],
metricOptions: [
{
label: MONITORS_DURATION_LABEL,
field: 'monitor.duration.us',
columnType: OPERATION_COLUMN,
}
],
labels: { ...FieldLabels, [SUMMARY_UP]: UP_LABEL, [SUMMARY_DOWN]: DOWN_LABEL },
}
```
## Lens Embeddable
Lens embeddable is what actually renders the chart in exploratory view.
Exploratory view generates the lens embeddable attributes as json and pass it to the component.
Based on configuration, exploratory view generates layers and columns.
Add a link to lens embeddable readme
#### Example
A simple usage of lens embeddable example and playground options
[embedded_lens_example](../../../../../../examples/embedded_lens_example)
## Exploratory view Embeddable
The primary purpose of the exploratory view is to embed it in observability solutions like uptime to replace
existing static visualizations,
For that purpose, all the configuration options we define in the exploratory view can be used as an embeddable
via a component that is exposed using observability plugin contract,
usage looks like this
`const ExploratoryViewComponent = props.plugins.observability.ExploratoryViewEmbeddable;
`
```
<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)

View file

@ -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();
});
});

View file

@ -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={() => {}}
/>
)}
</>
);
}

View file

@ -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>
);
}

View file

@ -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"
/>
}
/>
);
}

View file

@ -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.' }
);

View file

@ -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',
},
},
});
});
});

View 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 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;
}

View file

@ -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',
}
);

View file

@ -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}
/>
);
}

View file

@ -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);
});
});

Some files were not shown because too many files have changed in this diff Show more