Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Observability] Exploratory View initial skeleton #94426

Merged
merged 86 commits into from
Apr 5, 2021
Merged
Changes from 75 commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
9e7c169
wip
shahzad31 Mar 7, 2021
12c028f
Merge branch 'master' into exploratry-view
shahzad31 Mar 8, 2021
b089a87
Save progress
shahzad31 Mar 10, 2021
501a166
Merge branch 'master' into exploratry-view
shahzad31 Mar 10, 2021
3ed7c82
breakpoint
shahzad31 Mar 11, 2021
c303383
Merge branch 'master' into exploratry-view
shahzad31 Mar 11, 2021
73b2c41
more views
shahzad31 Mar 15, 2021
9166efc
add def col
shahzad31 Mar 15, 2021
3783b5f
Merge branch 'master' into exploratry-view
shahzad31 Mar 15, 2021
04316ab
update
shahzad31 Mar 15, 2021
9677edd
Merge branch 'master' into exploratry-view
shahzad31 Mar 15, 2021
e715ace
wip
shahzad31 Mar 15, 2021
5d3605f
Merge branch 'master' into exploratry-view
shahzad31 Mar 15, 2021
3d824be
wip
shahzad31 Mar 16, 2021
1db17ad
Merge branch 'master' into exploratry-view
shahzad31 Mar 16, 2021
306fa3f
wip
shahzad31 Mar 17, 2021
e2f281f
Merge branch 'master' into exploratry-view
shahzad31 Mar 17, 2021
700cdd7
fix more stuff
shahzad31 Mar 18, 2021
ee82e77
Merge branch 'master' into exploratry-view
shahzad31 Mar 19, 2021
1d5a7c8
update
shahzad31 Mar 21, 2021
91bcf95
Merge branch 'master' into exploratry-view
shahzad31 Mar 22, 2021
2ec3924
update filters
shahzad31 Mar 22, 2021
4f8f118
update
shahzad31 Mar 22, 2021
b2fbc2b
wip
shahzad31 Mar 23, 2021
e5c1e59
update
shahzad31 Mar 23, 2021
c14a2e5
update asset
shahzad31 Mar 24, 2021
165a540
update
shahzad31 Mar 24, 2021
e3b3eb5
update eslint
shahzad31 Mar 24, 2021
4b8aab4
Merge branch 'master' into exploratry-view
shahzad31 Mar 24, 2021
84c2abe
udate
shahzad31 Mar 24, 2021
75b87e1
wip
shahzad31 Mar 24, 2021
533698a
update plugin start/setup
shahzad31 Mar 24, 2021
f60a7f5
Merge branch 'master' into exploratry-view
shahzad31 Mar 24, 2021
805f062
update
shahzad31 Mar 24, 2021
8952b21
Merge branch 'master' into update-obsv-plugin-setup
shahzad31 Mar 24, 2021
68282c6
fix types
shahzad31 Mar 24, 2021
9886ef6
Merge branch 'master' into update-obsv-plugin-setup
shahzad31 Mar 25, 2021
a2121eb
update types
shahzad31 Mar 25, 2021
4b87ef9
Merge branch 'master' into exploratry-view
shahzad31 Mar 25, 2021
ad9574f
Merge branch 'master' into exploratry-view
shahzad31 Mar 25, 2021
52cbefa
fix type
shahzad31 Mar 25, 2021
a6dcd15
fix types
shahzad31 Mar 25, 2021
7af9e25
fix types
shahzad31 Mar 25, 2021
6c1a330
fix types
shahzad31 Mar 25, 2021
89d87e0
Merge branch 'master' into exploratry-view
shahzad31 Mar 25, 2021
a85d735
Merge branch 'master' into exploratry-view
shahzad31 Mar 25, 2021
3c670f2
fix test
shahzad31 Mar 25, 2021
d26f306
update test
shahzad31 Mar 25, 2021
ec98c08
Merge branch 'update-obsv-plugin-setup' into exploratry-view
shahzad31 Mar 25, 2021
43887f1
update types
shahzad31 Mar 26, 2021
967888a
fix types
shahzad31 Mar 26, 2021
8e5109f
Merge branch 'master' into exploratry-view
shahzad31 Mar 26, 2021
9acecc1
fix issue
shahzad31 Mar 26, 2021
082eaf4
Merge branch 'master' into exploratry-view
shahzad31 Mar 26, 2021
211716b
fix types
shahzad31 Mar 26, 2021
635be8e
test
shahzad31 Mar 26, 2021
50e100f
Merge branch 'master' into exploratry-view
shahzad31 Mar 26, 2021
dbb98c7
update tests
shahzad31 Mar 29, 2021
68d8243
added ests
shahzad31 Mar 29, 2021
1f4a516
update
shahzad31 Mar 29, 2021
31b60b6
Merge branch 'master' into exploratry-view
shahzad31 Mar 30, 2021
0557ad3
update
shahzad31 Mar 30, 2021
c512b3d
Merge branch 'master' into exploratry-view
shahzad31 Mar 30, 2021
e4ce7bd
update labels
shahzad31 Mar 30, 2021
1b57eff
Merge branch 'master' into exploratry-view
shahzad31 Mar 30, 2021
17120a6
Merge branch 'master' into exploratry-view
shahzad31 Mar 30, 2021
1710e5d
PR feedback
shahzad31 Mar 31, 2021
f162f96
update
shahzad31 Mar 31, 2021
c43e925
add loading state
shahzad31 Mar 31, 2021
adfefa3
fix types
shahzad31 Mar 31, 2021
e004a7a
fix types
shahzad31 Mar 31, 2021
01a1559
update test
shahzad31 Mar 31, 2021
5406e75
Merge branch 'master' into exploratry-view
shahzad31 Mar 31, 2021
2b5229b
update test
shahzad31 Mar 31, 2021
f894f18
update constants
shahzad31 Mar 31, 2021
7050460
fix type
shahzad31 Mar 31, 2021
06f305b
Merge branch 'master' into exploratry-view
kibanamachine Apr 1, 2021
62070d4
Merge branch 'master' into exploratry-view
shahzad31 Apr 1, 2021
9090e64
update mock
shahzad31 Apr 1, 2021
3e29da3
Merge branch 'exploratry-view' of https://github.com/shahzad31/kibana…
shahzad31 Apr 1, 2021
25c45e0
update test file
shahzad31 Apr 1, 2021
c18c829
debug tests
shahzad31 Apr 1, 2021
99d3596
Merge branch 'master' into exploratry-view
shahzad31 Apr 2, 2021
679a4a8
Merge branch 'master' into exploratry-view
shahzad31 Apr 3, 2021
9c86621
Merge branch 'master' into exploratry-view
shahzad31 Apr 5, 2021
7e5cf29
update plugin setup
shahzad31 Apr 5, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion x-pack/plugins/lens/public/mocks.tsx
Original file line number Diff line number Diff line change
@@ -18,7 +18,7 @@ const createStartContract = (): Start => {
}),
canUseEditor: jest.fn(() => true),
navigateToPrefilledEditor: jest.fn(),
getXyVisTypes: jest.fn().mockReturnValue(new Promise(() => visualizationTypes)),
getXyVisTypes: jest.fn().mockReturnValue(new Promise((resolve) => resolve(visualizationTypes))),
};
return startContract;
};
2 changes: 1 addition & 1 deletion x-pack/plugins/observability/kibana.json
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@
"kibanaVersion": "kibana",
"configPath": ["xpack", "observability"],
"optionalPlugins": ["licensing", "home", "usageCollection"],
"requiredPlugins": ["data"],
"requiredPlugins": ["data", "lens"],
"ui": true,
"server": true,
"requiredBundles": ["data", "kibanaReact", "kibanaUtils"]
116 changes: 116 additions & 0 deletions x-pack/plugins/observability/public/assets/kibana_dashboard_dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
116 changes: 116 additions & 0 deletions x-pack/plugins/observability/public/assets/kibana_dashboard_light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -59,13 +59,13 @@ export function Header({ color, datePicker = null, restrictWidth }: Props) {
</HeaderMenuPortal>
<Wrapper restrictWidth={restrictWidth}>
<EuiSpacer size="l" />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiIcon type="logoObservability" size="xxl" data-test-subj="observability-logo" />
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<EuiTitle>
<h1>
{i18n.translate('xpack.observability.home.title', {
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { EuiImage } from '@elastic/eui';
import styled from 'styled-components';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';

export function EmptyView() {
const {
services: { http },
} = useKibana();

return (
<Wrapper>
<EuiImage
alt="Visulization"
url={http!.basePath.prepend(`/plugins/observability/assets/kibana_dashboard_light.svg`)}
/>
</Wrapper>
);
}

const Wrapper = styled.div`
text-align: center;
opacity: 0.4;
height: 550px;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license 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 { mockIndexPattern, render } from '../rtl_helpers';
import { buildFilterLabel, FilterLabel } from './filter_label';
import * as useSeriesHook from '../hooks/use_series_filters';

describe('FilterLabel', function () {
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={'kpi-trends'}
removeFilter={jest.fn()}
/>
);

await waitFor(() => {
screen.getByText('elastic-co');
screen.getByText(/web application:/i);
screen.getByTitle('Delete Web Application: elastic-co');
screen.getByRole('button', {
name: /delete web application: elastic-co/i,
});
});
});

it('should delete filter', async function () {
const removeFilter = jest.fn();
render(
<FilterLabel
field={'service.name'}
value={'elastic-co'}
label={'Web Application'}
negate={false}
seriesId={'kpi-trends'}
removeFilter={removeFilter}
/>
);

await waitFor(() => {
fireEvent.click(screen.getByLabelText('Filter actions'));
});

fireEvent.click(screen.getByTestId('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={'kpi-trends'}
removeFilter={removeFilter}
/>
);

await waitFor(() => {
fireEvent.click(screen.getByLabelText('Filter actions'));
});

fireEvent.click(screen.getByTestId('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={'kpi-trends'}
removeFilter={jest.fn()}
/>
);

await waitFor(() => {
screen.getByText('elastic-co');
screen.getByText(/web application:/i);
screen.getByTitle('Delete NOT Web Application: elastic-co');
screen.getByRole('button', {
name: /delete not web application: elastic-co/i,
});
});
});

it('should build filter meta', function () {
expect(
buildFilterLabel({
field: 'user_agent.name',
label: 'Browser family',
indexPattern: mockIndexPattern,
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',
},
},
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { injectI18n } from '@kbn/i18n/react';
import { esFilters, Filter, IndexPattern } from '../../../../../../../../src/plugins/data/public';
import { useIndexPatternContext } from '../hooks/use_default_index_pattern';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { useSeriesFilters } from '../hooks/use_series_filters';

interface Props {
field: string;
label: string;
value: string;
seriesId: string;
negate: boolean;
definitionFilter?: boolean;
removeFilter: (field: string, value: string, notVal: boolean) => void;
}
export function buildFilterLabel({
field,
value,
label,
indexPattern,
negate,
}: {
label: string;
value: string;
negate: boolean;
field: string;
indexPattern: IndexPattern;
}) {
const indexField = indexPattern.getFieldByName(field)!;

const filter = esFilters.buildPhraseFilter(indexField, value, indexPattern);

filter.meta.value = value;
filter.meta.key = label;
filter.meta.alias = null;
filter.meta.negate = negate;
filter.meta.disabled = false;
filter.meta.type = 'phrase';

return filter;
}
export function FilterLabel({
label,
seriesId,
field,
value,
negate,
removeFilter,
definitionFilter,
}: Props) {
const FilterItem = injectI18n(esFilters.FilterItem);
shahzad31 marked this conversation as resolved.
Show resolved Hide resolved

const { indexPattern } = useIndexPatternContext();

const filter = buildFilterLabel({ field, value, label, indexPattern, negate });

const { invertFilter } = useSeriesFilters({ seriesId });

const {
services: { uiSettings },
} = useKibana();

return indexPattern ? (
<FilterItem
indexPatterns={[indexPattern]}
id={`${field}-${value}-${negate}`}
filter={filter}
onRemove={() => {
removeFilter(field, value, false);
}}
onUpdate={(filterN: Filter) => {
if (definitionFilter) {
// FIXME handle this use case
shahzad31 marked this conversation as resolved.
Show resolved Hide resolved
} else if (filterN.meta.negate !== negate) {
invertFilter({ field, value, negate });
}
}}
uiSettings={uiSettings!}
hiddenPanelOptions={['pinFilter', 'editFilter', 'disableFilter']}
/>
) : null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
shahzad31 marked this conversation as resolved.
Show resolved Hide resolved
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { AppDataType, ReportViewTypeId } from '../types';
import {
CLS_FIELD,
FCP_FIELD,
FID_FIELD,
LCP_FIELD,
TBT_FIELD,
} from './data/elasticsearch_fieldnames';

export const FieldLabels: Record<string, string> = {
'user_agent.name': 'Browser family',
'user_agent.version': 'Browser version',
'user_agent.os.name': 'Operating system',
'client.geo.country_name': 'Location',
'user_agent.device.name': 'Device',
'observer.geo.name': 'Observer location',
'service.name': 'Service Name',
'service.environment': 'Environment',

[LCP_FIELD]: 'Largest contentful paint',
[FCP_FIELD]: 'First contentful paint',
[TBT_FIELD]: 'Total blocking time',
[FID_FIELD]: 'First input delay',
[CLS_FIELD]: 'Cumulative layout shift',

'monitor.id': 'Monitor Id',
'monitor.status': 'Monitor Status',

'agent.hostname': 'Agent host',
'host.hostname': 'Host name',
'monitor.name': 'Monitor name',
'monitor.type': 'Monitor Type',
'url.port': 'Port',
tags: 'Tags',

// custom

'performance.metric': 'Metric',
'Business.KPI': 'KPI',
};

export const DataViewLabels: Record<ReportViewTypeId, string> = {
pld: 'Performance Distribution',
upd: 'Uptime monitor duration',
upp: 'Uptime pings',
svl: 'APM Service latency',
kpi: 'KPI over time',
tpt: 'APM Service throughput',
cpu: 'System CPU Usage',
logs: 'Logs Frequency',
mem: 'System Memory Usage',
nwk: 'Network Activity',
};

export const ReportToDataTypeMap: Record<ReportViewTypeId, AppDataType> = {
upd: 'synthetics',
upp: 'synthetics',
tpt: 'apm',
svl: 'apm',
kpi: 'rum',
pld: 'rum',
nwk: 'metrics',
mem: 'metrics',
logs: 'logs',
cpu: 'metrics',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { DataSeries } from '../types';
import { FieldLabels } from './constants';
import { OperationType } from '../../../../../../lens/public';

interface Props {
seriesId: string;
}

export function getCPUUsageLensConfig({ seriesId }: Props): DataSeries {
return {
id: seriesId,
reportType: 'cpu-usage',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'avg' as OperationType,
sourceField: 'system.cpu.user.pct',
label: 'CPU Usage %',
},
hasMetricType: true,
defaultFilters: [],
breakdowns: ['host.hostname'],
filters: [],
labels: { ...FieldLabels, 'host.hostname': 'Host name' },
reportDefinitions: [
{
field: 'agent.hostname',
required: true,
},
],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
shahzad31 marked this conversation as resolved.
Show resolved Hide resolved
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

export const CLOUD = 'cloud';
export const CLOUD_AVAILABILITY_ZONE = 'cloud.availability_zone';
export const CLOUD_PROVIDER = 'cloud.provider';
export const CLOUD_REGION = 'cloud.region';
export const CLOUD_MACHINE_TYPE = 'cloud.machine.type';

export const SERVICE = 'service';
export const SERVICE_NAME = 'service.name';
export const SERVICE_ENVIRONMENT = 'service.environment';
export const SERVICE_FRAMEWORK_NAME = 'service.framework.name';
export const SERVICE_FRAMEWORK_VERSION = 'service.framework.version';
export const SERVICE_LANGUAGE_NAME = 'service.language.name';
export const SERVICE_LANGUAGE_VERSION = 'service.language.version';
export const SERVICE_RUNTIME_NAME = 'service.runtime.name';
export const SERVICE_RUNTIME_VERSION = 'service.runtime.version';
export const SERVICE_NODE_NAME = 'service.node.name';
export const SERVICE_VERSION = 'service.version';

export const AGENT = 'agent';
export const AGENT_NAME = 'agent.name';
export const AGENT_VERSION = 'agent.version';

export const URL_FULL = 'url.full';
export const HTTP_REQUEST_METHOD = 'http.request.method';
export const HTTP_RESPONSE_STATUS_CODE = 'http.response.status_code';
export const USER_ID = 'user.id';
export const USER_AGENT_ORIGINAL = 'user_agent.original';
export const USER_AGENT_NAME = 'user_agent.name';
export const USER_AGENT_VERSION = 'user_agent.version';

export const DESTINATION_ADDRESS = 'destination.address';

export const OBSERVER_HOSTNAME = 'observer.hostname';
export const OBSERVER_VERSION_MAJOR = 'observer.version_major';
export const OBSERVER_LISTENING = 'observer.listening';
export const PROCESSOR_EVENT = 'processor.event';

export const TRANSACTION_DURATION = 'transaction.duration.us';
export const TRANSACTION_DURATION_HISTOGRAM = 'transaction.duration.histogram';
export const TRANSACTION_TYPE = 'transaction.type';
export const TRANSACTION_RESULT = 'transaction.result';
export const TRANSACTION_NAME = 'transaction.name';
export const TRANSACTION_ID = 'transaction.id';
export const TRANSACTION_SAMPLED = 'transaction.sampled';
export const TRANSACTION_BREAKDOWN_COUNT = 'transaction.breakdown.count';
export const TRANSACTION_PAGE_URL = 'transaction.page.url';
// for transaction metrics
export const TRANSACTION_ROOT = 'transaction.root';

export const EVENT_OUTCOME = 'event.outcome';

export const TRACE_ID = 'trace.id';

export const SPAN_DURATION = 'span.duration.us';
export const SPAN_TYPE = 'span.type';
export const SPAN_SUBTYPE = 'span.subtype';
export const SPAN_SELF_TIME_SUM = 'span.self_time.sum.us';
export const SPAN_ACTION = 'span.action';
export const SPAN_NAME = 'span.name';
export const SPAN_ID = 'span.id';
export const SPAN_DESTINATION_SERVICE_RESOURCE = 'span.destination.service.resource';
export const SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT =
'span.destination.service.response_time.count';

export const SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM =
'span.destination.service.response_time.sum.us';

// Parent ID for a transaction or span
export const PARENT_ID = 'parent.id';

export const ERROR_GROUP_ID = 'error.grouping_key';
export const ERROR_CULPRIT = 'error.culprit';
export const ERROR_LOG_LEVEL = 'error.log.level';
export const ERROR_LOG_MESSAGE = 'error.log.message';
export const ERROR_EXC_MESSAGE = 'error.exception.message'; // only to be used in es queries, since error.exception is now an array
export const ERROR_EXC_HANDLED = 'error.exception.handled'; // only to be used in es queries, since error.exception is now an array
export const ERROR_EXC_TYPE = 'error.exception.type';
export const ERROR_PAGE_URL = 'error.page.url';

// METRICS
export const METRIC_SYSTEM_FREE_MEMORY = 'system.memory.actual.free';
export const METRIC_SYSTEM_TOTAL_MEMORY = 'system.memory.total';
export const METRIC_SYSTEM_CPU_PERCENT = 'system.cpu.total.norm.pct';
export const METRIC_PROCESS_CPU_PERCENT = 'system.process.cpu.total.norm.pct';
export const METRIC_CGROUP_MEMORY_LIMIT_BYTES = 'system.process.cgroup.memory.mem.limit.bytes';
export const METRIC_CGROUP_MEMORY_USAGE_BYTES = 'system.process.cgroup.memory.mem.usage.bytes';

export const METRIC_JAVA_HEAP_MEMORY_MAX = 'jvm.memory.heap.max';
export const METRIC_JAVA_HEAP_MEMORY_COMMITTED = 'jvm.memory.heap.committed';
export const METRIC_JAVA_HEAP_MEMORY_USED = 'jvm.memory.heap.used';
export const METRIC_JAVA_NON_HEAP_MEMORY_MAX = 'jvm.memory.non_heap.max';
export const METRIC_JAVA_NON_HEAP_MEMORY_COMMITTED = 'jvm.memory.non_heap.committed';
export const METRIC_JAVA_NON_HEAP_MEMORY_USED = 'jvm.memory.non_heap.used';
export const METRIC_JAVA_THREAD_COUNT = 'jvm.thread.count';
export const METRIC_JAVA_GC_COUNT = 'jvm.gc.count';
export const METRIC_JAVA_GC_TIME = 'jvm.gc.time';

export const LABEL_NAME = 'labels.name';

export const HOST = 'host';
export const HOST_NAME = 'host.hostname';
export const HOST_OS_PLATFORM = 'host.os.platform';
export const CONTAINER_ID = 'container.id';
export const KUBERNETES = 'kubernetes';
export const POD_NAME = 'kubernetes.pod.name';

export const CLIENT_GEO_COUNTRY_ISO_CODE = 'client.geo.country_iso_code';
export const CLIENT_GEO_COUNTRY_NAME = 'client.geo.country_name';

// RUM Labels
export const TRANSACTION_URL = 'url.full';
export const CLIENT_GEO = 'client.geo';
export const USER_AGENT_DEVICE = 'user_agent.device.name';
export const USER_AGENT_OS = 'user_agent.os.name';

export const TRANSACTION_TIME_TO_FIRST_BYTE = 'transaction.marks.agent.timeToFirstByte';
export const TRANSACTION_DOM_INTERACTIVE = 'transaction.marks.agent.domInteractive';

export const FCP_FIELD = 'transaction.marks.agent.firstContentfulPaint';
export const LCP_FIELD = 'transaction.marks.agent.largestContentfulPaint';
export const TBT_FIELD = 'transaction.experience.tbt';
export const FID_FIELD = 'transaction.experience.fid';
export const CLS_FIELD = 'transaction.experience.cls';

export const PROFILE_ID = 'profile.id';
export const PROFILE_DURATION = 'profile.duration';
export const PROFILE_TOP_ID = 'profile.top.id';
export const PROFILE_STACK = 'profile.stack';

export const PROFILE_SAMPLES_COUNT = 'profile.samples.count';
export const PROFILE_CPU_NS = 'profile.cpu.ns';
export const PROFILE_WALL_US = 'profile.wall.us';

export const PROFILE_ALLOC_OBJECTS = 'profile.alloc_objects.count';
export const PROFILE_ALLOC_SPACE = 'profile.alloc_space.bytes';
export const PROFILE_INUSE_OBJECTS = 'profile.inuse_objects.count';
export const PROFILE_INUSE_SPACE = 'profile.inuse_space.bytes';
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const sampleAttribute = {
title: 'Prefilled from exploratory view app',
description: '',
visualizationType: 'lnsXY',
references: [
{ id: 'apm-*', name: 'indexpattern-datasource-current-indexpattern', type: 'index-pattern' },
{ id: 'apm-*', name: 'indexpattern-datasource-layer-layer1', type: 'index-pattern' },
],
state: {
datasourceStates: {
indexpattern: {
layers: {
layer1: {
columnOrder: ['x-axis-column', 'y-axis-column'],
columns: {
'x-axis-column': {
sourceField: 'transaction.duration.us',
label: 'Page load time',
dataType: 'number',
operationType: 'range',
isBucketed: true,
scale: 'interval',
params: {
type: 'histogram',
ranges: [{ from: 0, to: 1000, label: '' }],
maxBars: 'auto',
},
},
'y-axis-column': {
dataType: 'number',
isBucketed: false,
label: 'Pages loaded',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
},
},
incompleteColumns: {},
},
},
},
},
visualization: {
legend: { isVisible: true, position: 'right' },
valueLabels: 'hide',
fittingFunction: 'Linear',
curveType: 'CURVE_MONOTONE_X',
axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true },
tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true },
gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true },
preferredSeriesType: 'line',
layers: [
{
accessors: ['y-axis-column'],
layerId: 'layer1',
seriesType: 'line',
yConfig: [{ forAccessor: 'y-axis-column', color: 'green' }],
xAccessor: 'x-axis-column',
},
],
},
query: { query: '', language: 'kuery' },
filters: [
{ meta: { index: 'apm-*' }, query: { match_phrase: { 'transaction.type': 'page-load' } } },
{ meta: { index: 'apm-*' }, query: { match_phrase: { 'processor.event': 'transaction' } } },
],
},
};

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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 { ReportViewTypes } from '../types';
import { getPerformanceDistLensConfig } from './performance_dist_config';
import { getMonitorDurationConfig } from './monitor_duration_config';
import { getServiceLatencyLensConfig } from './service_latency_config';
import { getMonitorPingsConfig } from './monitor_pings_config';
import { getServiceThroughputLensConfig } from './service_throughput_config';
import { getKPITrendsLensConfig } from './kpi_trends_config';
import { getCPUUsageLensConfig } from './cpu_usage_config';
import { getMemoryUsageLensConfig } from './memory_usage_config';
import { getNetworkActivityLensConfig } from './network_activity_config';
import { getLogsFrequencyLensConfig } from './logs_frequency_config';
import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns';

interface Props {
reportType: keyof typeof ReportViewTypes;
seriesId: string;
indexPattern: IIndexPattern;
}

export const getDefaultConfigs = ({ reportType, seriesId, indexPattern }: Props) => {
switch (ReportViewTypes[reportType]) {
case 'page-load-dist':
return getPerformanceDistLensConfig({ seriesId, indexPattern });
case 'kpi-trends':
return getKPITrendsLensConfig({ seriesId, indexPattern });
case 'uptime-duration':
return getMonitorDurationConfig({ seriesId });
case 'uptime-pings':
return getMonitorPingsConfig({ seriesId });
case 'service-latency':
return getServiceLatencyLensConfig({ seriesId, indexPattern });
case 'service-throughput':
return getServiceThroughputLensConfig({ seriesId, indexPattern });
case 'cpu-usage':
return getCPUUsageLensConfig({ seriesId });
case 'memory-usage':
return getMemoryUsageLensConfig({ seriesId });
case 'network-activity':
return getNetworkActivityLensConfig({ seriesId });
case 'logs-frequency':
return getLogsFrequencyLensConfig({ seriesId });
default:
return getKPITrendsLensConfig({ seriesId, indexPattern });
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { ConfigProps, DataSeries } from '../types';
import { FieldLabels } from './constants';
import { buildPhraseFilter } from './utils';
import {
CLIENT_GEO_COUNTRY_NAME,
PROCESSOR_EVENT,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
TRANSACTION_TYPE,
USER_AGENT_DEVICE,
USER_AGENT_NAME,
USER_AGENT_OS,
USER_AGENT_VERSION,
} from './data/elasticsearch_fieldnames';

export function getKPITrendsLensConfig({ seriesId, indexPattern }: ConfigProps): DataSeries {
return {
id: seriesId,
defaultSeriesType: 'bar_stacked',
reportType: 'kpi-trends',
seriesTypes: ['bar', 'bar_stacked'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'count',
label: 'Page views',
},
hasMetricType: false,
defaultFilters: [
USER_AGENT_OS,
CLIENT_GEO_COUNTRY_NAME,
USER_AGENT_DEVICE,
{
field: USER_AGENT_NAME,
nested: USER_AGENT_VERSION,
},
],
breakdowns: [USER_AGENT_NAME, USER_AGENT_OS, CLIENT_GEO_COUNTRY_NAME, USER_AGENT_DEVICE],
filters: [
buildPhraseFilter(TRANSACTION_TYPE, 'page-load', indexPattern),
buildPhraseFilter(PROCESSOR_EVENT, 'transaction', indexPattern),
],
labels: { ...FieldLabels, SERVICE_NAME: 'Web Application' },
reportDefinitions: [
{
field: SERVICE_NAME,
required: true,
},
{
field: SERVICE_ENVIRONMENT,
},
{
field: 'Business.KPI',
custom: true,
defaultValue: 'Records',
options: [
{
field: 'Records',
label: 'Page views',
},
],
},
],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,387 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { LensAttributes } from './lens_attributes';
import { mockIndexPattern } from '../rtl_helpers';
import { getDefaultConfigs } from './default_configs';
import { sampleAttribute } from './data/sample_attribute';
import { LCP_FIELD, SERVICE_NAME } from './data/elasticsearch_fieldnames';
import { USER_AGENT_NAME } from './data/elasticsearch_fieldnames';

describe('Lens Attribute', () => {
const reportViewConfig = getDefaultConfigs({
reportType: 'pld',
indexPattern: mockIndexPattern,
seriesId: 'series-id',
});

let lnsAttr: LensAttributes;

beforeEach(() => {
lnsAttr = new LensAttributes(mockIndexPattern, reportViewConfig, 'line', [], 'count', {});
});

it('should return expected json', function () {
expect(lnsAttr.getJSON()).toEqual(sampleAttribute);
});

it('should return main y axis', function () {
expect(lnsAttr.getMainYAxis()).toEqual({
dataType: 'number',
isBucketed: false,
label: 'Pages loaded',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
});
});

it('should return expected field type', function () {
expect(JSON.stringify(lnsAttr.getFieldMeta('transaction.type'))).toEqual(
JSON.stringify({
count: 0,
name: 'transaction.type',
type: 'string',
esTypes: ['keyword'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
})
);
});

it('should return expected field type for custom field with default value', function () {
expect(JSON.stringify(lnsAttr.getFieldMeta('performance.metric'))).toEqual(
JSON.stringify({
count: 0,
name: 'transaction.duration.us',
type: 'number',
esTypes: ['long'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
})
);
});

it('should return expected field type for custom field with passed value', function () {
lnsAttr = new LensAttributes(mockIndexPattern, reportViewConfig, 'line', [], 'count', {
'performance.metric': LCP_FIELD,
});

expect(JSON.stringify(lnsAttr.getFieldMeta('performance.metric'))).toEqual(
JSON.stringify({
count: 0,
name: LCP_FIELD,
type: 'number',
esTypes: ['scaled_float'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
})
);
});

it('should return expected number column', function () {
expect(lnsAttr.getNumberColumn('transaction.duration.us')).toEqual({
dataType: 'number',
isBucketed: true,
label: 'Page load time',
operationType: 'range',
params: {
maxBars: 'auto',
ranges: [
{
from: 0,
label: '',
to: 1000,
},
],
type: 'histogram',
},
scale: 'interval',
sourceField: 'transaction.duration.us',
});
});

it('should return expected date histogram column', function () {
expect(lnsAttr.getDateHistogramColumn('@timestamp')).toEqual({
dataType: 'date',
isBucketed: true,
label: '@timestamp',
operationType: 'date_histogram',
params: {
interval: 'auto',
},
scale: 'interval',
sourceField: '@timestamp',
});
});

it('should return main x axis', function () {
expect(lnsAttr.getXAxis()).toEqual({
dataType: 'number',
isBucketed: true,
label: 'Page load time',
operationType: 'range',
params: {
maxBars: 'auto',
ranges: [
{
from: 0,
label: '',
to: 1000,
},
],
type: 'histogram',
},
scale: 'interval',
sourceField: 'transaction.duration.us',
});
});

it('should return first layer', function () {
expect(lnsAttr.getLayer()).toEqual({
columnOrder: ['x-axis-column', 'y-axis-column'],
columns: {
'x-axis-column': {
dataType: 'number',
isBucketed: true,
label: 'Page load time',
operationType: 'range',
params: {
maxBars: 'auto',
ranges: [
{
from: 0,
label: '',
to: 1000,
},
],
type: 'histogram',
},
scale: 'interval',
sourceField: 'transaction.duration.us',
},
'y-axis-column': {
dataType: 'number',
isBucketed: false,
label: 'Pages loaded',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
},
},
incompleteColumns: {},
});
});

it('should return expected XYState', function () {
expect(lnsAttr.getXyState()).toEqual({
axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true },
curveType: 'CURVE_MONOTONE_X',
fittingFunction: 'Linear',
gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true },
layers: [
{
accessors: ['y-axis-column'],
layerId: 'layer1',
palette: undefined,
seriesType: 'line',
xAccessor: 'x-axis-column',
yConfig: [{ color: 'green', forAccessor: 'y-axis-column' }],
},
],
legend: { isVisible: true, position: 'right' },
preferredSeriesType: 'line',
tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true },
valueLabels: 'hide',
});
});

describe('ParseFilters function', function () {
it('should parse default filters', function () {
expect(lnsAttr.parseFilters()).toEqual([
{ meta: { index: 'apm-*' }, query: { match_phrase: { 'transaction.type': 'page-load' } } },
{ meta: { index: 'apm-*' }, query: { match_phrase: { 'processor.event': 'transaction' } } },
]);
});

it('should parse default and ui filters', function () {
lnsAttr = new LensAttributes(
mockIndexPattern,
reportViewConfig,
'line',
[
{ field: SERVICE_NAME, values: ['elastic-co', 'kibana-front'] },
{ field: USER_AGENT_NAME, values: ['Firefox'], notValues: ['Chrome'] },
],
'count',
{}
);

expect(lnsAttr.parseFilters()).toEqual([
{ meta: { index: 'apm-*' }, query: { match_phrase: { 'transaction.type': 'page-load' } } },
{ meta: { index: 'apm-*' }, query: { match_phrase: { 'processor.event': 'transaction' } } },
{
meta: {
index: 'apm-*',
key: 'service.name',
params: ['elastic-co', 'kibana-front'],
type: 'phrases',
value: 'elastic-co, kibana-front',
},
query: {
bool: {
minimum_should_match: 1,
should: [
{
match_phrase: {
'service.name': 'elastic-co',
},
},
{
match_phrase: {
'service.name': 'kibana-front',
},
},
],
},
},
},
{
meta: {
index: 'apm-*',
},
query: {
match_phrase: {
'user_agent.name': 'Firefox',
},
},
},
{
meta: {
index: 'apm-*',
negate: true,
},
query: {
match_phrase: {
'user_agent.name': 'Chrome',
},
},
},
]);
});
});

describe('Layer breakdowns', function () {
it('should add breakdown column', function () {
lnsAttr.addBreakdown(USER_AGENT_NAME);

expect(lnsAttr.visualization.layers).toEqual([
{
accessors: ['y-axis-column'],
layerId: 'layer1',
palette: undefined,
seriesType: 'line',
splitAccessor: 'break-down-column',
xAccessor: 'x-axis-column',
yConfig: [{ color: 'green', forAccessor: 'y-axis-column' }],
},
]);

expect(lnsAttr.layers.layer1).toEqual({
columnOrder: ['x-axis-column', 'break-down-column', 'y-axis-column'],
columns: {
'break-down-column': {
dataType: 'string',
isBucketed: true,
label: 'Top values of Browser family',
operationType: 'terms',
params: {
missingBucket: false,
orderBy: { columnId: 'y-axis-column', type: 'column' },
orderDirection: 'desc',
otherBucket: true,
size: 3,
},
scale: 'ordinal',
sourceField: 'user_agent.name',
},
'x-axis-column': {
dataType: 'number',
isBucketed: true,
label: 'Page load time',
operationType: 'range',
params: {
maxBars: 'auto',
ranges: [{ from: 0, label: '', to: 1000 }],
type: 'histogram',
},
scale: 'interval',
sourceField: 'transaction.duration.us',
},
'y-axis-column': {
dataType: 'number',
isBucketed: false,
label: 'Pages loaded',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
},
},
incompleteColumns: {},
});
});

it('should remove breakdown column', function () {
lnsAttr.addBreakdown(USER_AGENT_NAME);

lnsAttr.removeBreakdown();

expect(lnsAttr.visualization.layers).toEqual([
{
accessors: ['y-axis-column'],
layerId: 'layer1',
palette: undefined,
seriesType: 'line',
xAccessor: 'x-axis-column',
yConfig: [{ color: 'green', forAccessor: 'y-axis-column' }],
},
]);

expect(lnsAttr.layers.layer1.columnOrder).toEqual(['x-axis-column', 'y-axis-column']);

expect(lnsAttr.layers.layer1.columns).toEqual({
'x-axis-column': {
dataType: 'number',
isBucketed: true,
label: 'Page load time',
operationType: 'range',
params: {
maxBars: 'auto',
ranges: [{ from: 0, label: '', to: 1000 }],
type: 'histogram',
},
scale: 'interval',
sourceField: 'transaction.duration.us',
},
'y-axis-column': {
dataType: 'number',
isBucketed: false,
label: 'Pages loaded',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
},
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import {
CountIndexPatternColumn,
DateHistogramIndexPatternColumn,
LastValueIndexPatternColumn,
OperationType,
PersistedIndexPatternLayer,
RangeIndexPatternColumn,
SeriesType,
TypedLensByValueInput,
XYState,
XYCurveType,
DataType,
} from '../../../../../../lens/public';
import {
buildPhraseFilter,
buildPhrasesFilter,
IndexPattern,
} from '../../../../../../../../src/plugins/data/common';
import { FieldLabels } from './constants';
import { DataSeries, UrlFilter } from '../types';

function getLayerReferenceName(layerId: string) {
return `indexpattern-datasource-layer-${layerId}`;
}

export class LensAttributes {
indexPattern: IndexPattern;
layers: Record<string, PersistedIndexPatternLayer>;
visualization: XYState;
filters: UrlFilter[];
seriesType: SeriesType;
reportViewConfig: DataSeries;
reportDefinitions: Record<string, string>;

constructor(
indexPattern: IndexPattern,
reportViewConfig: DataSeries,
seriesType?: SeriesType,
filters?: UrlFilter[],
metricType?: OperationType,
reportDefinitions?: Record<string, string>
) {
this.indexPattern = indexPattern;
this.layers = {};
this.filters = filters ?? [];
this.reportDefinitions = reportDefinitions ?? {};

if (typeof reportViewConfig.yAxisColumn.operationType !== undefined && metricType) {
reportViewConfig.yAxisColumn.operationType = metricType;
}
this.seriesType = seriesType ?? reportViewConfig.defaultSeriesType;
this.reportViewConfig = reportViewConfig;
this.layers.layer1 = this.getLayer();
this.visualization = this.getXyState();
}

addBreakdown(sourceField: string) {
const fieldMeta = this.indexPattern.getFieldByName(sourceField);

this.layers.layer1.columns['break-down-column'] = {
sourceField,
label: `Top values of ${FieldLabels[sourceField]}`,
dataType: fieldMeta?.type as DataType,
operationType: 'terms',
scale: 'ordinal',
isBucketed: true,
params: {
size: 3,
orderBy: { type: 'column', columnId: 'y-axis-column' },
orderDirection: 'desc',
otherBucket: true,
missingBucket: false,
},
};

this.layers.layer1.columnOrder = ['x-axis-column', 'break-down-column', 'y-axis-column'];

this.visualization.layers[0].splitAccessor = 'break-down-column';
}

removeBreakdown() {
delete this.layers.layer1.columns['break-down-column'];

this.layers.layer1.columnOrder = ['x-axis-column', 'y-axis-column'];

this.visualization.layers[0].splitAccessor = undefined;
}

getNumberColumn(sourceField: string): RangeIndexPatternColumn {
return {
sourceField,
label: this.reportViewConfig.labels[sourceField],
dataType: 'number',
operationType: 'range',
isBucketed: true,
scale: 'interval',
params: {
type: 'histogram',
ranges: [{ from: 0, to: 1000, label: '' }],
maxBars: 'auto',
},
};
}

getDateHistogramColumn(sourceField: string): DateHistogramIndexPatternColumn {
return {
sourceField,
dataType: 'date',
isBucketed: true,
label: '@timestamp',
operationType: 'date_histogram',
params: { interval: 'auto' },
scale: 'interval',
};
}

getXAxis():
| LastValueIndexPatternColumn
| DateHistogramIndexPatternColumn
| RangeIndexPatternColumn {
const { xAxisColumn } = this.reportViewConfig;

const { type: fieldType, name: fieldName } = this.getFieldMeta(xAxisColumn.sourceField)!;

if (fieldType === 'date') {
return this.getDateHistogramColumn(fieldName);
}
if (fieldType === 'number') {
return this.getNumberColumn(fieldName);
}

// FIXME review my approach again
return this.getDateHistogramColumn(fieldName);
shahzad31 marked this conversation as resolved.
Show resolved Hide resolved
}

getFieldMeta(sourceField?: string) {
let xAxisField = sourceField;

if (xAxisField) {
const rdf = this.reportViewConfig.reportDefinitions ?? [];

const customField = rdf.find(({ field }) => field === xAxisField);

if (customField) {
if (this.reportDefinitions[xAxisField]) {
xAxisField = this.reportDefinitions[xAxisField];
} else if (customField.defaultValue) {
xAxisField = customField.defaultValue;
} else if (customField.options?.[0].field) {
xAxisField = customField.options?.[0].field;
}
}

return this.indexPattern.getFieldByName(xAxisField);
}
}

getMainYAxis() {
return {
dataType: 'number',
isBucketed: false,
label: 'Count of records',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
...this.reportViewConfig.yAxisColumn,
} as CountIndexPatternColumn;
}

getLayer() {
return {
columnOrder: ['x-axis-column', 'y-axis-column'],
columns: {
'x-axis-column': this.getXAxis(),
'y-axis-column': this.getMainYAxis(),
},
incompleteColumns: {},
};
}

getXyState(): XYState {
return {
legend: { isVisible: true, position: 'right' },
valueLabels: 'hide',
fittingFunction: 'Linear',
curveType: 'CURVE_MONOTONE_X' as XYCurveType,
axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true },
tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true },
gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true },
preferredSeriesType: 'line',
layers: [
{
accessors: ['y-axis-column'],
layerId: 'layer1',
seriesType: this.seriesType ?? 'line',
palette: this.reportViewConfig.palette,
yConfig: [{ forAccessor: 'y-axis-column', color: 'green' }],
xAccessor: 'x-axis-column',
},
],
};
}

parseFilters() {
const defaultFilters = this.reportViewConfig.filters ?? [];
const parsedFilters = this.reportViewConfig.filters ? [...defaultFilters] : [];

this.filters.forEach(({ field, values = [], notValues = [] }) => {
const fieldMeta = this.indexPattern.fields.find((fieldT) => fieldT.name === field)!;

if (values?.length > 0) {
if (values?.length > 1) {
const multiFilter = buildPhrasesFilter(fieldMeta, values, this.indexPattern);
parsedFilters.push(multiFilter);
} else {
const filter = buildPhraseFilter(fieldMeta, values[0], this.indexPattern);
parsedFilters.push(filter);
}
}

if (notValues?.length > 0) {
if (notValues?.length > 1) {
const multiFilter = buildPhrasesFilter(fieldMeta, notValues, this.indexPattern);
multiFilter.meta.negate = true;
parsedFilters.push(multiFilter);
} else {
const filter = buildPhraseFilter(fieldMeta, notValues[0], this.indexPattern);
filter.meta.negate = true;
parsedFilters.push(filter);
}
}
});

return parsedFilters;
}

getJSON(): TypedLensByValueInput['attributes'] {
return {
title: 'Prefilled from exploratory view app',
description: '',
visualizationType: 'lnsXY',
references: [
{
id: this.indexPattern.id!,
name: 'indexpattern-datasource-current-indexpattern',
type: 'index-pattern',
},
{
id: this.indexPattern.id!,
name: getLayerReferenceName('layer1'),
type: 'index-pattern',
},
],
state: {
datasourceStates: {
indexpattern: {
layers: this.layers,
},
},
visualization: this.visualization,
query: { query: '', language: 'kuery' },
filters: this.parseFilters(),
},
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { DataSeries } from '../types';
import { FieldLabels } from './constants';

interface Props {
seriesId: string;
}

export function getLogsFrequencyLensConfig({ seriesId }: Props): DataSeries {
return {
id: seriesId,
reportType: 'logs-frequency',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'count',
},
hasMetricType: false,
defaultFilters: [],
breakdowns: ['agent.hostname'],
filters: [],
labels: { ...FieldLabels },
reportDefinitions: [
{
field: 'agent.hostname',
required: true,
},
],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { DataSeries } from '../types';
import { FieldLabels } from './constants';
import { OperationType } from '../../../../../../lens/public';

interface Props {
seriesId: string;
}

export function getMemoryUsageLensConfig({ seriesId }: Props): DataSeries {
return {
id: seriesId,
reportType: 'memory-usage',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'avg' as OperationType,
sourceField: 'system.memory.used.pct',
label: 'Memory Usage %',
},
hasMetricType: true,
defaultFilters: [],
breakdowns: ['host.hostname'],
filters: [],
labels: { ...FieldLabels, 'host.hostname': 'Host name' },
reportDefinitions: [
{
field: 'host.hostname',
required: true,
},
],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { DataSeries } from '../types';
import { FieldLabels } from './constants';
import { OperationType } from '../../../../../../lens/public';

interface Props {
seriesId: string;
}

export function getMonitorDurationConfig({ seriesId }: Props): DataSeries {
return {
id: seriesId,
reportType: 'uptime-duration',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar_stacked'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'avg' as OperationType,
sourceField: 'monitor.duration.us',
label: 'Monitor duration (ms)',
},
hasMetricType: true,
defaultFilters: ['monitor.type', 'observer.geo.name', 'tags'],
breakdowns: [
'observer.geo.name',
'monitor.name',
'monitor.id',
'monitor.type',
'tags',
'url.port',
],
filters: [],
reportDefinitions: [
{
field: 'monitor.id',
},
],
labels: { ...FieldLabels },
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { DataSeries } from '../types';
import { FieldLabels } from './constants';

interface Props {
seriesId: string;
}

export function getMonitorPingsConfig({ seriesId }: Props): DataSeries {
return {
id: seriesId,
reportType: 'uptime-pings',
defaultSeriesType: 'bar_stacked',
seriesTypes: ['bar_stacked', 'bar'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'count',
label: 'Monitor pings',
},
hasMetricType: false,
defaultFilters: ['observer.geo.name'],
breakdowns: ['monitor.status', 'observer.geo.name', 'monitor.type'],
filters: [],
palette: { type: 'palette', name: 'status' },
reportDefinitions: [
{
field: 'monitor.id',
},
{
field: 'url.full',
},
],
labels: { ...FieldLabels },
};
}
Original file line number Diff line number Diff line change
@@ -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 { DataSeries } from '../types';
import { FieldLabels } from './constants';
import { OperationType } from '../../../../../../lens/public';

interface Props {
seriesId: string;
}

export function getNetworkActivityLensConfig({ seriesId }: Props): DataSeries {
return {
id: seriesId,
reportType: 'network-activity',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'avg' as OperationType,
sourceField: 'system.memory.used.pct',
},
hasMetricType: true,
defaultFilters: [],
breakdowns: ['host.hostname'],
filters: [],
labels: { ...FieldLabels, 'host.hostname': 'Host name' },
reportDefinitions: [
{
field: 'host.hostname',
required: true,
},
],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { ConfigProps, DataSeries } from '../types';
import { FieldLabels } from './constants';
import { buildPhraseFilter } from './utils';
import {
CLIENT_GEO_COUNTRY_NAME,
CLS_FIELD,
FCP_FIELD,
FID_FIELD,
LCP_FIELD,
PROCESSOR_EVENT,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
TBT_FIELD,
TRANSACTION_DURATION,
TRANSACTION_TYPE,
USER_AGENT_DEVICE,
USER_AGENT_NAME,
USER_AGENT_OS,
USER_AGENT_VERSION,
} from './data/elasticsearch_fieldnames';

export function getPerformanceDistLensConfig({ seriesId, indexPattern }: ConfigProps): DataSeries {
return {
id: seriesId ?? 'unique-key',
reportType: 'page-load-dist',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar'],
xAxisColumn: {
sourceField: 'performance.metric',
},
yAxisColumn: {
operationType: 'count',
label: 'Pages loaded',
},
hasMetricType: false,
defaultFilters: [
USER_AGENT_OS,
CLIENT_GEO_COUNTRY_NAME,
USER_AGENT_DEVICE,
{
field: USER_AGENT_NAME,
nested: USER_AGENT_VERSION,
},
],
breakdowns: [USER_AGENT_NAME, USER_AGENT_OS, CLIENT_GEO_COUNTRY_NAME, USER_AGENT_DEVICE],
reportDefinitions: [
{
field: SERVICE_NAME,
required: true,
},
{
field: SERVICE_ENVIRONMENT,
},
{
field: 'performance.metric',
custom: true,
defaultValue: TRANSACTION_DURATION,
options: [
{ label: 'Page load time', field: TRANSACTION_DURATION },
{ label: 'First contentful paint', field: FCP_FIELD },
{ label: 'Total blocking time', field: TBT_FIELD },
// FIXME, review if we need these descriptions
{ label: 'Largest contentful paint', field: LCP_FIELD, description: 'Core web vital' },
shahzad31 marked this conversation as resolved.
Show resolved Hide resolved
{ label: 'First input delay', field: FID_FIELD, description: 'Core web vital' },
{ label: 'Cumulative layout shift', field: CLS_FIELD, description: 'Core web vital' },
],
},
],
filters: [
buildPhraseFilter(TRANSACTION_TYPE, 'page-load', indexPattern),
buildPhraseFilter(PROCESSOR_EVENT, 'transaction', indexPattern),
],
labels: {
...FieldLabels,
[SERVICE_NAME]: 'Web Application',
[TRANSACTION_DURATION]: 'Page load time',
},
};
}
Original file line number Diff line number Diff line change
@@ -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 { ConfigProps, DataSeries } from '../types';
import { FieldLabels } from './constants';
import { buildPhraseFilter } from './utils';
import { OperationType } from '../../../../../../lens/public';

export function getServiceLatencyLensConfig({ seriesId, indexPattern }: ConfigProps): DataSeries {
return {
id: seriesId,
reportType: 'service-latency',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'avg' as OperationType,
sourceField: 'transaction.duration.us',
label: 'Latency',
},
hasMetricType: true,
defaultFilters: [
'user_agent.name',
'user_agent.os.name',
'client.geo.country_name',
'user_agent.device.name',
],
breakdowns: [
'user_agent.name',
'user_agent.os.name',
'client.geo.country_name',
'user_agent.device.name',
],
filters: [buildPhraseFilter('transaction.type', 'request', indexPattern)],
labels: { ...FieldLabels },
reportDefinitions: [
{
field: 'service.name',
required: true,
},
{
field: 'service.environment',
},
],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { ConfigProps, DataSeries } from '../types';
import { FieldLabels } from './constants';
import { buildPhraseFilter } from './utils';
import { OperationType } from '../../../../../../lens/public';

export function getServiceThroughputLensConfig({
seriesId,
indexPattern,
}: ConfigProps): DataSeries {
return {
id: seriesId,
reportType: 'service-latency',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'avg' as OperationType,
sourceField: 'transaction.duration.us',
label: 'Throughput',
},
hasMetricType: true,
defaultFilters: [
'user_agent.name',
'user_agent.os.name',
'client.geo.country_name',
'user_agent.device.name',
],
breakdowns: [
'user_agent.name',
'user_agent.os.name',
'client.geo.country_name',
'user_agent.device.name',
],
filters: [buildPhraseFilter('transaction.type', 'request', indexPattern)],
labels: { ...FieldLabels },
reportDefinitions: [
{
field: 'service.name',
required: true,
},
{
field: 'service.environment',
},
],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

export const URL_KEYS = {
METRIC_TYPE: 'mt',
REPORT_TYPE: 'rt',
SERIES_TYPE: 'st',
BREAK_DOWN: 'bd',
FILTERS: 'ft',
REPORT_DEFINITIONS: 'rdf',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import rison, { RisonValue } from 'rison-node';
import type { AllSeries, AllShortSeries } from '../hooks/use_url_strorage';
import type { SeriesUrl } from '../types';
import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns';
import { esFilters } from '../../../../../../../../src/plugins/data/public';
import { URL_KEYS } from './url_constants';

export function convertToShortUrl(series: SeriesUrl) {
shahzad31 marked this conversation as resolved.
Show resolved Hide resolved
const {
metric,
seriesType,
reportType,
breakdown,
filters,
reportDefinitions,
...restSeries
} = series;

return {
[URL_KEYS.METRIC_TYPE]: metric,
[URL_KEYS.REPORT_TYPE]: reportType,
[URL_KEYS.SERIES_TYPE]: seriesType,
[URL_KEYS.BREAK_DOWN]: breakdown,
[URL_KEYS.FILTERS]: filters,
[URL_KEYS.REPORT_DEFINITIONS]: reportDefinitions,
...restSeries,
};
}

export function createExploratoryViewUrl(allSeries: AllSeries, baseHref = '') {
const allSeriesIds = Object.keys(allSeries);

const allShortSeries: AllShortSeries = {};

allSeriesIds.forEach((seriesKey) => {
allShortSeries[seriesKey] = convertToShortUrl(allSeries[seriesKey]);
});

return (
baseHref +
`/app/observability/exploratory-view#?sr=${rison.encode(allShortSeries as RisonValue)}`
);
}

export function buildPhraseFilter(field: string, value: any, indexPattern: IIndexPattern) {
const fieldMeta = indexPattern.fields.find((fieldT) => fieldT.name === field)!;
return esFilters.buildPhraseFilter(fieldMeta, value, indexPattern);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { within } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/dom';
import { render, mockUrlStorage, mockCore } from './rtl_helpers';
import { ExploratoryView } from './exploratory_view';
import { getStubIndexPattern } from '../../../../../../../src/plugins/data/public/test_utils';
import * as obsvInd from '../../../utils/observability_index_patterns';

describe('ExploratoryView', () => {
beforeEach(() => {
const indexPattern = getStubIndexPattern(
'apm-*',
() => {},
'@timestamp',
[
{
name: '@timestamp',
type: 'date',
esTypes: ['date'],
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
],
mockCore() as any
);

jest.spyOn(obsvInd, 'ObservabilityIndexPatterns').mockReturnValue({
getIndexPattern: jest.fn().mockReturnValue(indexPattern),
} as any);
});

it('renders exploratory view', async () => {
render(<ExploratoryView />);

await waitFor(() => {
screen.getByText(/open in lens/i);
screen.getByRole('heading', { name: /exploratory view/i });
screen.getByRole('img', { name: /visulization/i });
screen.getByText(/add series/i);
screen.getByText(/no series found, please add a series\./i);
});
});

it('can add, cancel new series', async () => {
render(<ExploratoryView />);

await fireEvent.click(screen.getByText(/add series/i));

await waitFor(() => {
screen.getByText(/open in lens/i);
screen.getByText(/select a data type to start building a series\./i);
screen.getByRole('table', { name: /this table contains 1 rows\./i });
const button = screen.getByRole('button', { name: /add/i });
within(button).getByText(/add/i);
});

await fireEvent.click(screen.getByText(/cancel/i));

await waitFor(() => {
screen.getByText(/add series/i);
});
});

it('renders lens component when there is series', async () => {
mockUrlStorage({
data: {
'uptime-pings-histogram': {
reportType: 'upp',
breakdown: 'monitor.status',
time: { from: 'now-15m', to: 'now' },
},
},
});

render(<ExploratoryView />);

await waitFor(() => {
screen.getByText(/open in lens/i);
screen.getByRole('heading', { name: /uptime pings/i });
screen.getByText(/uptime-pings-histogram/i);
screen.getByText(/Lens Embeddable Component/i);
screen.getByRole('table', { name: /this table contains 1 rows\./i });
});
});
});
Original file line number Diff line number Diff line change
@@ -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, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { EuiLoadingSpinner, EuiPanel } from '@elastic/eui';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../plugin';
import { ExploratoryViewHeader } from './header/header';
import { SeriesEditor } from './series_editor/series_editor';
import { useUrlStorage } from './hooks/use_url_strorage';
import { useLensAttributes } from './hooks/use_lens_attributes';
import { EmptyView } from './components/empty_view';
import { useIndexPatternContext } from './hooks/use_default_index_pattern';
import { TypedLensByValueInput } from '../../../../../lens/public';

export function ExploratoryView() {
const {
services: { lens },
} = useKibana<ObservabilityPublicPluginsStart>();

const [lensAttributes, setLensAttributes] = useState<TypedLensByValueInput['attributes'] | null>(
null
);

const { indexPattern } = useIndexPatternContext();

const LensComponent = lens.EmbeddableComponent;

const { firstSeriesId: seriesId, firstSeries: series } = useUrlStorage();

const lensAttributesT = useLensAttributes({
seriesId,
indexPattern,
});

useEffect(() => {
setLensAttributes(lensAttributesT);
// eslint-disable-next-line react-hooks/exhaustive-deps
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why no exhaustive deps here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am using a weird technique here, lens attribute is a huge object so using JSON.stringify to make sure it's equality check works

}, [JSON.stringify(lensAttributesT ?? {}), series?.reportType, series?.time?.from]);

return (
<EuiPanel style={{ maxWidth: 1800, minWidth: 1200, margin: '0 auto' }}>
<ExploratoryViewHeader lensAttributes={lensAttributes} seriesId={seriesId} />
{!indexPattern && (
<SpinnerWrap>
<EuiLoadingSpinner size="xl" />
</SpinnerWrap>
)}

{lensAttributes && seriesId && series?.reportType && series?.time ? (
<LensComponent
id="exploratoryView"
style={{ height: 550 }}
timeRange={series?.time}
attributes={lensAttributes}
/>
) : (
<EmptyView />
)}
<SeriesEditor />
</EuiPanel>
);
}

const SpinnerWrap = styled.div`
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license 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 { mockUrlStorage, render } from '../rtl_helpers';
import { ExploratoryViewHeader } from './header';
import { fireEvent } from '@testing-library/dom';

describe('ExploratoryViewHeader', function () {
it('should render properly', function () {
const { getByText } = render(
<ExploratoryViewHeader
seriesId={'dummy-series'}
lensAttributes={{ title: 'Performance distribution' } as any}
/>
);
getByText('Open in Lens');
});

it('should be able to click open in lens', function () {
mockUrlStorage({
data: {
'uptime-pings-histogram': {
reportType: 'upp',
breakdown: 'monitor.status',
time: { from: 'now-15m', to: 'now' },
},
},
});

const { getByText, core } = render(
<ExploratoryViewHeader
seriesId={'dummy-series'}
lensAttributes={{ title: 'Performance distribution' } as any}
/>
);
fireEvent.click(getByText('Open in Lens'));

expect(core?.lens?.navigateToPrefilledEditor).toHaveBeenCalledTimes(1);
expect(core?.lens?.navigateToPrefilledEditor).toHaveBeenCalledWith({
attributes: { title: 'Performance distribution' },
id: '',
timeRange: {
from: 'now-15m',
to: 'now',
},
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { TypedLensByValueInput } from '../../../../../../lens/public';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../../plugin';
import { DataViewLabels } from '../configurations/constants';
import { useUrlStorage } from '../hooks/use_url_strorage';

interface Props {
seriesId: string;
lensAttributes: TypedLensByValueInput['attributes'] | null;
}

export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) {
const {
services: { lens },
} = useKibana<ObservabilityPublicPluginsStart>();

const { series } = useUrlStorage(seriesId);

return (
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem>
<EuiText>
<h2>
{DataViewLabels[series.reportType] ??
i18n.translate('xpack.observability.expView.heading.label', {
defaultMessage: 'Exploratory view',
})}
</h2>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
iconType="lensApp"
fullWidth={false}
isDisabled={!lens.canUseEditor() || lensAttributes === null}
onClick={() => {
if (lensAttributes) {
lens.navigateToPrefilledEditor({
id: '',
timeRange: series.time,
attributes: lensAttributes,
});
}
}}
>
{i18n.translate('xpack.observability.expView.heading.openInLens', {
defaultMessage: 'Open in Lens',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React, { createContext, useContext, Context, useState, useEffect } from 'react';
import { IndexPattern } from '../../../../../../../../src/plugins/data/common';
import { AppDataType } from '../types';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../../plugin';
import { ObservabilityIndexPatterns } from '../../../../utils/observability_index_patterns';

export interface IIndexPatternContext {
indexPattern: IndexPattern;
loadIndexPattern: (dataType: AppDataType) => void;
}

export const IndexPatternContext = createContext<Partial<IIndexPatternContext>>({});

interface ProviderProps {
indexPattern?: IndexPattern;
children: JSX.Element;
}

export function IndexPatternContextProvider({
children,
indexPattern: initialIndexPattern,
}: ProviderProps) {
const [indexPattern, setIndexPattern] = useState(initialIndexPattern);

useEffect(() => {
setIndexPattern(initialIndexPattern);
}, [initialIndexPattern]);

const {
services: { data },
} = useKibana<ObservabilityPublicPluginsStart>();

const loadIndexPattern = async (dataType: AppDataType) => {
const obsvIndexP = new ObservabilityIndexPatterns(data);
const indPattern = await obsvIndexP.getIndexPattern(dataType);
setIndexPattern(indPattern!);
};

return (
<IndexPatternContext.Provider
value={{
indexPattern,
loadIndexPattern,
}}
>
{children}
</IndexPatternContext.Provider>
);
}

export const useIndexPatternContext = () => {
return useContext((IndexPatternContext as unknown) as Context<IIndexPatternContext>);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useFetcher } from '../../../..';
import { IKbnUrlStateStorage } from '../../../../../../../../src/plugins/kibana_utils/public';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../../plugin';
import { AllShortSeries } from './use_url_strorage';
import { ReportToDataTypeMap } from '../configurations/constants';
import {
DataType,
ObservabilityIndexPatterns,
} from '../../../../utils/observability_index_patterns';

export const useInitExploratoryView = (storage: IKbnUrlStateStorage) => {
const {
services: { data },
} = useKibana<ObservabilityPublicPluginsStart>();

const allSeriesKey = 'sr';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be in a constant?


const allSeries = storage.get<AllShortSeries>(allSeriesKey) ?? {};

const allSeriesIds = Object.keys(allSeries);

const firstSeriesId = allSeriesIds?.[0];

const firstSeries = allSeries[firstSeriesId];

const { data: indexPattern } = useFetcher(() => {
const obsvIndexP = new ObservabilityIndexPatterns(data);
let reportType: DataType = 'apm';
if (firstSeries?.rt) {
reportType = ReportToDataTypeMap[firstSeries?.rt];
}

return obsvIndexP.getIndexPattern(reportType);
}, [firstSeries?.rt, data]);

return indexPattern;
};
Original file line number Diff line number Diff line change
@@ -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 { useMemo } from 'react';
import { TypedLensByValueInput } from '../../../../../../lens/public';
import { LensAttributes } from '../configurations/lens_attributes';
import { useUrlStorage } from './use_url_strorage';
import { getDefaultConfigs } from '../configurations/default_configs';

import { IndexPattern } from '../../../../../../../../src/plugins/data/common';
import { DataSeries, SeriesUrl, UrlFilter } from '../types';

interface Props {
seriesId: string;
indexPattern?: IndexPattern | null;
}

export const getFiltersFromDefs = (
reportDefinitions: SeriesUrl['reportDefinitions'],
dataViewConfig: DataSeries
) => {
const rdfFilters = Object.entries(reportDefinitions ?? {}).map(([field, value]) => {
return {
field,
values: [value],
};
}) as UrlFilter[];

// let's filter out custom fields
return rdfFilters.filter(({ field }) => {
const rdf = dataViewConfig.reportDefinitions.find(({ field: fd }) => field === fd);
return !rdf?.custom;
});
};

export const useLensAttributes = ({
seriesId,
indexPattern,
}: Props): TypedLensByValueInput['attributes'] | null => {
const { series } = useUrlStorage(seriesId);

const { breakdown, seriesType, metric: metricType, reportType, reportDefinitions = {} } =
series ?? {};

return useMemo(() => {
if (!indexPattern || !reportType) {
return null;
}

const dataViewConfig = getDefaultConfigs({
seriesId,
reportType,
indexPattern,
});

const filters: UrlFilter[] = (series.filters ?? []).concat(
getFiltersFromDefs(reportDefinitions, dataViewConfig)
);

const lensAttributes = new LensAttributes(
indexPattern,
dataViewConfig,
seriesType,
filters,
metricType,
reportDefinitions
);

if (breakdown) {
lensAttributes.addBreakdown(breakdown);
}

return lensAttributes.getJSON();
}, [
indexPattern,
breakdown,
seriesType,
metricType,
reportType,
reportDefinitions,
seriesId,
series.filters,
]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { useUrlStorage } from './use_url_strorage';
import { UrlFilter } from '../types';

export interface UpdateFilter {
field: string;
value: string;
negate?: boolean;
}

export const useSeriesFilters = ({ seriesId }: { seriesId: string }) => {
const { series, setSeries } = useUrlStorage(seriesId);

const filters = series.filters ?? [];

const removeFilter = ({ field, value, negate }: UpdateFilter) => {
const filtersN = filters.map((filter) => {
if (filter.field === field) {
if (negate) {
const notValuesN = filter.notValues?.filter((val) => val !== value);
return { ...filter, notValues: notValuesN };
} else {
const valuesN = filter.values?.filter((val) => val !== value);
return { ...filter, values: valuesN };
}
}

return filter;
});
setSeries(seriesId, { ...series, filters: filtersN });
};

const addFilter = ({ field, value, negate }: UpdateFilter) => {
const currFilter: UrlFilter = { field };
if (negate) {
currFilter.notValues = [value];
} else {
currFilter.values = [value];
}
if (filters.length === 0) {
setSeries(seriesId, { ...series, filters: [currFilter] });
} else {
setSeries(seriesId, {
...series,
filters: [currFilter, ...filters.filter((ft) => ft.field !== field)],
});
}
};

const updateFilter = ({ field, value, negate }: UpdateFilter) => {
const currFilter: UrlFilter | undefined = filters.find(({ field: fd }) => field === fd) ?? {
field,
};

const currNotValues = currFilter.notValues ?? [];
const currValues = currFilter.values ?? [];

const notValues = currNotValues.filter((val) => val !== value);
const values = currValues.filter((val) => val !== value);

if (negate) {
notValues.push(value);
} else {
values.push(value);
}

currFilter.notValues = notValues.length > 0 ? notValues : undefined;
currFilter.values = values.length > 0 ? values : undefined;

const otherFilters = filters.filter(({ field: fd }) => fd !== field);

if (notValues.length > 0 || values.length > 0) {
setSeries(seriesId, { ...series, filters: [...otherFilters, currFilter] });
} else {
setSeries(seriesId, { ...series, filters: otherFilters });
}
};

const setFilter = ({ field, value, negate }: UpdateFilter) => {
const currFilter: UrlFilter | undefined = filters.find(({ field: fd }) => field === fd);

if (!currFilter) {
addFilter({ field, value, negate });
} else {
updateFilter({ field, value, negate });
}
};

const invertFilter = ({ field, value, negate }: UpdateFilter) => {
updateFilter({ field, value, negate: !negate });
};

return { invertFilter, setFilter, removeFilter };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React, { createContext, useContext, Context } from 'react';
import { IKbnUrlStateStorage } from '../../../../../../../../src/plugins/kibana_utils/public';
import type { AppDataType, ReportViewTypeId, SeriesUrl, UrlFilter } from '../types';
import { convertToShortUrl } from '../configurations/utils';
import { OperationType, SeriesType } from '../../../../../../lens/public';
import { URL_KEYS } from '../configurations/url_constants';

export const UrlStorageContext = createContext<IKbnUrlStateStorage | null>(null);

interface ProviderProps {
storage: IKbnUrlStateStorage;
}

export function UrlStorageContextProvider({
children,
storage,
}: ProviderProps & { children: JSX.Element }) {
return <UrlStorageContext.Provider value={storage}>{children}</UrlStorageContext.Provider>;
}

function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl {
const { mt, st, rt, bd, ft, time, rdf, ...restSeries } = newValue;
return {
metric: mt,
reportType: rt!,
seriesType: st,
breakdown: bd,
filters: ft!,
time: time!,
reportDefinitions: rdf,
...restSeries,
};
}

interface ShortUrlSeries {
[URL_KEYS.METRIC_TYPE]?: OperationType;
[URL_KEYS.REPORT_TYPE]?: ReportViewTypeId;
[URL_KEYS.SERIES_TYPE]?: SeriesType;
[URL_KEYS.BREAK_DOWN]?: string;
[URL_KEYS.FILTERS]?: UrlFilter[];
[URL_KEYS.REPORT_DEFINITIONS]?: Record<string, string>;
time?: {
to: string;
from: string;
};
dataType?: AppDataType;
}

export type AllShortSeries = Record<string, ShortUrlSeries>;
export type AllSeries = Record<string, SeriesUrl>;

export const NEW_SERIES_KEY = 'newSeriesKey';

export function useUrlStorage(seriesId?: string) {
const allSeriesKey = 'sr';
const storage = useContext((UrlStorageContext as unknown) as Context<IKbnUrlStateStorage>);
let series: SeriesUrl = {} as SeriesUrl;
const allShortSeries = storage.get<AllShortSeries>(allSeriesKey) ?? {};

const allSeriesIds = Object.keys(allShortSeries);

const allSeries: AllSeries = {};

allSeriesIds.forEach((seriesKey) => {
allSeries[seriesKey] = convertFromShortUrl(allShortSeries[seriesKey]);
});

if (seriesId) {
series = allSeries?.[seriesId] ?? ({} as SeriesUrl);
}

const setSeries = async (seriesIdN: string, newValue: SeriesUrl) => {
allShortSeries[seriesIdN] = convertToShortUrl(newValue);
allSeries[seriesIdN] = newValue;
return storage.set(allSeriesKey, allShortSeries);
};

const removeSeries = (seriesIdN: string) => {
delete allShortSeries[seriesIdN];
delete allSeries[seriesIdN];
storage.set(allSeriesKey, allShortSeries);
};

const firstSeriesId = allSeriesIds?.[0];

return {
storage,
setSeries,
removeSeries,
series,
firstSeriesId,
allSeries,
allSeriesIds,
firstSeries: allSeries?.[firstSeriesId],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React, { useContext } from 'react';
import { i18n } from '@kbn/i18n';
import { useHistory } from 'react-router-dom';
import { ThemeContext } from 'styled-components';
import { ExploratoryView } from './exploratory_view';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../plugin';
import { useBreadcrumbs } from '../../../hooks/use_breadcrumbs';
import { IndexPatternContextProvider } from './hooks/use_default_index_pattern';
import {
createKbnUrlStateStorage,
withNotifyOnErrors,
} from '../../../../../../../src/plugins/kibana_utils/public/';
import { UrlStorageContextProvider } from './hooks/use_url_strorage';
import { useInitExploratoryView } from './hooks/use_init_exploratory_view';
import { WithHeaderLayout } from '../../app/layout/with_header';

export function ExploratoryViewPage() {
useBreadcrumbs([
{
text: i18n.translate('xpack.observability.overview.exploratoryView', {
defaultMessage: 'Exploratory view',
}),
},
]);

const theme = useContext(ThemeContext);

const {
services: { uiSettings, notifications },
} = useKibana<ObservabilityPublicPluginsStart>();

const history = useHistory();

const kbnUrlStateStorage = createKbnUrlStateStorage({
history,
useHash: uiSettings!.get('state:storeInSessionStorage'),
...withNotifyOnErrors(notifications!.toasts),
shahzad31 marked this conversation as resolved.
Show resolved Hide resolved
});

const indexPattern = useInitExploratoryView(kbnUrlStateStorage);

return (
<WithHeaderLayout
headerColor={theme.eui.euiColorEmptyShade}
bodyColor={theme.eui.euiPageBackgroundColor}
>
{indexPattern ? (
<IndexPatternContextProvider indexPattern={indexPattern!}>
<UrlStorageContextProvider storage={kbnUrlStateStorage}>
<ExploratoryView />
</UrlStorageContextProvider>
</IndexPatternContextProvider>
) : null}
</WithHeaderLayout>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
/*
shahzad31 marked this conversation as resolved.
Show resolved Hide resolved
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { of } from 'rxjs';
import React, { ReactElement } from 'react';
import { stringify } from 'query-string';
// eslint-disable-next-line import/no-extraneous-dependencies
import { render as reactTestLibRender, RenderOptions } from '@testing-library/react';
import { Router } from 'react-router-dom';
import { createMemoryHistory, History } from 'history';
import { CoreStart } from 'kibana/public';
import { I18nProvider } from '@kbn/i18n/react';
import { coreMock } from 'src/core/public/mocks';
import {
KibanaServices,
KibanaContextProvider,
} from '../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../plugin';
import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common';
import { lensPluginMock } from '../../../../../lens/public/mocks';
import { IndexPatternContextProvider } from './hooks/use_default_index_pattern';
import { AllSeries, UrlStorageContextProvider } from './hooks/use_url_strorage';
import {
withNotifyOnErrors,
createKbnUrlStateStorage,
} from '../../../../../../../src/plugins/kibana_utils/public';
import * as fetcherHook from '../../../hooks/use_fetcher';
import * as useUrlHook from './hooks/use_url_strorage';
import * as useSeriesFilterHook from './hooks/use_series_filters';
import * as useHasDataHook from '../../../hooks/use_has_data';
import * as useValuesListHook from '../../../hooks/use_values_list';

// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { getStubIndexPattern } from '../../../../../../../src/plugins/data/public/index_patterns/index_pattern.stub';
import indexPatternData from './configurations/data/test_index_pattern.json';

// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { setIndexPatterns } from '../../../../../../../src/plugins/data/public/services';
import { IndexPatternsContract } from '../../../../../../../src/plugins/data/common/index_patterns/index_patterns';
import { UrlFilter } from './types';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';

interface KibanaProps {
services?: KibanaServices;
}

export interface KibanaProviderOptions<ExtraCore> {
core?: ExtraCore & Partial<CoreStart>;
kibanaProps?: KibanaProps;
}

interface MockKibanaProviderProps<ExtraCore extends Partial<CoreStart>>
extends KibanaProviderOptions<ExtraCore> {
children: ReactElement;
history: History;
}

type MockRouterProps<ExtraCore extends Partial<CoreStart>> = MockKibanaProviderProps<ExtraCore>;

type Url =
| string
| {
path: string;
queryParams: Record<string, string | number>;
};

interface RenderRouterOptions<ExtraCore> extends KibanaProviderOptions<ExtraCore> {
history?: History;
renderOptions?: Omit<RenderOptions, 'queries'>;
url?: Url;
}

function getSetting<T = any>(key: string): T {
if (key === 'timepicker:quickRanges') {
return ([
{
display: 'Today',
from: 'now/d',
to: 'now/d',
},
] as unknown) as T;
}
return ('MMM D, YYYY @ HH:mm:ss.SSS' as unknown) as T;
}

function setSetting$<T = any>(key: string): T {
return (of('MMM D, YYYY @ HH:mm:ss.SSS') as unknown) as T;
}

/* default mock core */
const defaultCore = coreMock.createStart();
export const mockCore: () => Partial<CoreStart & ObservabilityPublicPluginsStart> = () => {
const core: Partial<CoreStart & ObservabilityPublicPluginsStart> = {
...defaultCore,
application: {
...defaultCore.application,
getUrlForApp: () => '/app/observability',
navigateToUrl: jest.fn(),
capabilities: {
...defaultCore.application.capabilities,
observability: {
'alerting:save': true,
configureSettings: true,
save: true,
show: true,
},
},
},
uiSettings: {
...defaultCore.uiSettings,
get: getSetting,
get$: setSetting$,
},
lens: lensPluginMock.createStartContract(),
data: dataPluginMock.createStartContract(),
};

return core;
};

/* Mock Provider Components */
export function MockKibanaProvider<ExtraCore extends Partial<CoreStart>>({
children,
core,
history,
kibanaProps,
}: MockKibanaProviderProps<ExtraCore>) {
const { notifications } = core!;

const kbnUrlStateStorage = createKbnUrlStateStorage({
history,
useHash: false,
...withNotifyOnErrors(notifications!.toasts),
});

const indexPattern = mockIndexPattern;

setIndexPatterns(({
...[indexPattern],
get: async () => indexPattern,
} as unknown) as IndexPatternsContract);

return (
<KibanaContextProvider services={{ ...core }} {...kibanaProps}>
<EuiThemeProvider darkMode={false}>
<I18nProvider>
<IndexPatternContextProvider indexPattern={indexPattern}>
<UrlStorageContextProvider storage={kbnUrlStateStorage}>
{children}
</UrlStorageContextProvider>
</IndexPatternContextProvider>
</I18nProvider>
</EuiThemeProvider>
</KibanaContextProvider>
);
}

export function MockRouter<ExtraCore>({
children,
core,
history = createMemoryHistory(),
kibanaProps,
}: MockRouterProps<ExtraCore>) {
return (
<Router history={history}>
<MockKibanaProvider core={core} kibanaProps={kibanaProps} history={history}>
{children}
</MockKibanaProvider>
</Router>
);
}

/* Custom react testing library render */
export function render<ExtraCore>(
ui: ReactElement,
{
history = createMemoryHistory(),
core: customCore,
kibanaProps,
renderOptions,
url,
}: RenderRouterOptions<ExtraCore> = {}
) {
if (url) {
history = getHistoryFromUrl(url);
}

const core = {
...mockCore(),
...customCore,
};

return {
...reactTestLibRender(
<MockRouter history={history} kibanaProps={kibanaProps} core={core}>
{ui}
</MockRouter>,
renderOptions
),
history,
core,
};
}

const getHistoryFromUrl = (url: Url) => {
if (typeof url === 'string') {
return createMemoryHistory({
initialEntries: [url],
});
}

return createMemoryHistory({
initialEntries: [url.path + stringify(url.queryParams)],
});
};

export const mockFetcher = (data: any) => {
return jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({
data,
status: fetcherHook.FETCH_STATUS.SUCCESS,
refetch: jest.fn(),
});
};

export const mockUseHasData = () => {
const onRefreshTimeRange = jest.fn();
const spy = jest.spyOn(useHasDataHook, 'useHasData').mockReturnValue({
onRefreshTimeRange,
} as any);
return { spy, onRefreshTimeRange };
};

export const mockUseValuesList = (values?: string[]) => {
const onRefreshTimeRange = jest.fn();
const spy = jest.spyOn(useValuesListHook, 'useValuesList').mockReturnValue({
values: values ?? [],
} as any);
return { spy, onRefreshTimeRange };
};

export const mockUrlStorage = ({
data,
filters,
breakdown,
}: {
data?: AllSeries;
filters?: UrlFilter[];
breakdown?: string;
}) => {
const mockDataSeries = data || {
'performance-distribution': {
reportType: 'pld',
breakdown: breakdown || 'user_agent.name',
time: { from: 'now-15m', to: 'now' },
...(filters ? { filters } : {}),
},
};
const allSeriesIds = Object.keys(mockDataSeries);
const firstSeriesId = allSeriesIds?.[0];

const series = mockDataSeries[firstSeriesId];

const removeSeries = jest.fn();
const setSeries = jest.fn();

const spy = jest.spyOn(useUrlHook, 'useUrlStorage').mockReturnValue({
firstSeriesId,
allSeriesIds,
removeSeries,
setSeries,
series,
firstSeries: mockDataSeries[firstSeriesId],
allSeries: mockDataSeries,
} as any);

return { spy, removeSeries, setSeries };
};

export function mockUseSeriesFilter() {
const removeFilter = jest.fn();
const invertFilter = jest.fn();
const setFilter = jest.fn();
const spy = jest.spyOn(useSeriesFilterHook, 'useSeriesFilters').mockReturnValue({
removeFilter,
invertFilter,
setFilter,
});

return {
spy,
removeFilter,
invertFilter,
setFilter,
};
}

const hist = createMemoryHistory();
export const mockHistory = {
...hist,
createHref: jest.fn(({ pathname }) => `/observability${pathname}`),
push: jest.fn(),
location: {
...hist.location,
pathname: '/current-path',
},
};

export const mockIndexPattern = getStubIndexPattern(
'apm-*',
() => {},
'@timestamp',
JSON.parse(indexPatternData.attributes.fields),
mockCore() as any
);
Original file line number Diff line number Diff line change
@@ -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 React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { mockUrlStorage, render } from '../../rtl_helpers';
import { dataTypes, DataTypesCol } from './data_types_col';
import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';

describe('DataTypesCol', function () {
it('should render properly', function () {
const { getByText } = render(<DataTypesCol />);

dataTypes.forEach(({ label }) => {
getByText(label);
});
});

it('should set series on change', function () {
const { setSeries } = mockUrlStorage({});

render(<DataTypesCol />);

fireEvent.click(screen.getByText(/user experience\(rum\)/i));

expect(setSeries).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith('newSeriesKey', { dataType: 'rum' });
});

it('should set series on change on already selected', function () {
const { setSeries } = mockUrlStorage({
data: {
[NEW_SERIES_KEY]: {
dataType: 'synthetics',
reportType: 'upp',
breakdown: 'monitor.status',
time: { from: 'now-15m', to: 'now' },
},
},
});

render(<DataTypesCol />);

const button = screen.getByRole('button', {
name: /Synthetic Monitoring/i,
});

expect(button.classList).toContain('euiButton--fill');

fireEvent.click(button);

// undefined on click selected
expect(setSeries).toHaveBeenCalledWith('newSeriesKey', { dataType: undefined });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { AppDataType } from '../../types';
import { useIndexPatternContext } from '../../hooks/use_default_index_pattern';
import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_strorage';

export const dataTypes: Array<{ id: AppDataType; label: string }> = [
{ id: 'synthetics', label: 'Synthetic Monitoring' },
shahzad31 marked this conversation as resolved.
Show resolved Hide resolved
{ id: 'rum', label: 'User Experience(RUM)' },
{ id: 'logs', label: 'Logs' },
{ id: 'metrics', label: 'Metrics' },
{ id: 'apm', label: 'APM' },
];

export function DataTypesCol() {
const { series, setSeries } = useUrlStorage(NEW_SERIES_KEY);

const { loadIndexPattern } = useIndexPatternContext();

const onDataTypeChange = (dataType?: AppDataType) => {
if (dataType) {
loadIndexPattern(dataType);
}
setSeries(NEW_SERIES_KEY, { dataType } as any);
};

const selectedDataType = series.dataType;

return (
<EuiFlexGroup direction="column" gutterSize="xs">
{dataTypes.map(({ id: dataTypeId, label }) => (
<EuiFlexItem key={dataTypeId}>
<EuiButton
size="s"
iconSide="right"
iconType="arrowRight"
color={selectedDataType === dataTypeId ? 'primary' : 'text'}
fill={selectedDataType === dataTypeId}
onClick={() => {
onDataTypeChange(dataTypeId === selectedDataType ? undefined : dataTypeId);
}}
>
{label}
</EuiButton>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
}
Original file line number Diff line number Diff line change
@@ -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 { fireEvent, screen } from '@testing-library/react';
import { render } from '../../../../../utils/test_helper';
import { getDefaultConfigs } from '../../configurations/default_configs';
import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers';
import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
import { ReportBreakdowns } from './report_breakdowns';
import { USER_AGENT_OS } from '../../configurations/data/elasticsearch_fieldnames';

describe('Series Builder ReportBreakdowns', function () {
const dataViewSeries = getDefaultConfigs({
reportType: 'pld',
indexPattern: mockIndexPattern,
seriesId: NEW_SERIES_KEY,
});

it('should render properly', function () {
mockUrlStorage({});

render(<ReportBreakdowns dataViewSeries={dataViewSeries} />);

screen.getByText('Select an option: , is selected');
screen.getAllByText('Browser family');
});

it('should set new series breakdown on change', function () {
const { setSeries } = mockUrlStorage({});

render(<ReportBreakdowns dataViewSeries={dataViewSeries} />);

const btn = screen.getByRole('button', {
name: /select an option: Browser family , is selected/i,
hidden: true,
});

fireEvent.click(btn);

fireEvent.click(screen.getByText(/operating system/i));

expect(setSeries).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith('newSeriesKey', {
breakdown: USER_AGENT_OS,
reportType: 'pld',
time: { from: 'now-15m', to: 'now' },
});
});
it('should set undefined on new series on no select breakdown', function () {
const { setSeries } = mockUrlStorage({});

render(<ReportBreakdowns dataViewSeries={dataViewSeries} />);

const btn = screen.getByRole('button', {
shahzad31 marked this conversation as resolved.
Show resolved Hide resolved
name: /select an option: Browser family , is selected/i,
hidden: true,
});

fireEvent.click(btn);

fireEvent.click(screen.getByText(/no breakdown/i));

expect(setSeries).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith('newSeriesKey', {
breakdown: undefined,
reportType: 'pld',
time: { from: 'now-15m', to: 'now' },
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { Breakdowns } from '../../series_editor/columns/breakdowns';
import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
import { DataSeries } from '../../types';

export function ReportBreakdowns({ dataViewSeries }: { dataViewSeries: DataSeries }) {
return <Breakdowns breakdowns={dataViewSeries.breakdowns ?? []} seriesId={NEW_SERIES_KEY} />;
}
Original file line number Diff line number Diff line change
@@ -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 { fireEvent, screen } from '@testing-library/react';
import { getDefaultConfigs } from '../../configurations/default_configs';
import { mockIndexPattern, mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers';
import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
import { ReportDefinitionCol } from './report_definition_col';
import { SERVICE_NAME } from '../../configurations/data/elasticsearch_fieldnames';

describe('Series Builder ReportDefinitionCol', function () {
const dataViewSeries = getDefaultConfigs({
reportType: 'pld',
indexPattern: mockIndexPattern,
seriesId: NEW_SERIES_KEY,
});

const { setSeries } = mockUrlStorage({
data: {
'performance-dist': {
dataType: 'rum',
reportType: 'pld',
time: { from: 'now-30d', to: 'now' },
reportDefinitions: { [SERVICE_NAME]: 'elastic-co' },
},
},
});

it('should render properly', async function () {
render(<ReportDefinitionCol dataViewSeries={dataViewSeries} />);

screen.getByText('Web Application');
screen.getByText('Environment');
screen.getByText('Select an option: Page load time, is selected');
screen.getByText('Page load time');
});

it('should render selected report definitions', function () {
render(<ReportDefinitionCol dataViewSeries={dataViewSeries} />);

screen.getByText('elastic-co');
});

it('should be able to remove selected definition', function () {
render(<ReportDefinitionCol dataViewSeries={dataViewSeries} />);

const removeBtn = screen.getByText(/elastic-co/i);

fireEvent.click(removeBtn);

expect(setSeries).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith('newSeriesKey', {
dataType: 'rum',
reportDefinitions: {},
reportType: 'pld',
time: { from: 'now-30d', to: 'now' },
});
});

it('should be able to unselected selected definition', async function () {
mockUseValuesList(['elastic-co']);
render(<ReportDefinitionCol dataViewSeries={dataViewSeries} />);

const definitionBtn = screen.getByText(/web application/i);

fireEvent.click(definitionBtn);

screen.getByText('Apply');
shahzad31 marked this conversation as resolved.
Show resolved Hide resolved
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useIndexPatternContext } from '../../hooks/use_default_index_pattern';
import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_strorage';
import { CustomReportField } from '../custom_report_field';
import FieldValueSuggestions from '../../../field_value_suggestions';
import { DataSeries } from '../../types';

export function ReportDefinitionCol({ dataViewSeries }: { dataViewSeries: DataSeries }) {
const { indexPattern } = useIndexPatternContext();

const { series, setSeries } = useUrlStorage(NEW_SERIES_KEY);

const { reportDefinitions: rtd = {} } = series;

const { reportDefinitions, labels, filters } = dataViewSeries;

const onChange = (field: string, value?: string) => {
if (!value) {
delete rtd[field];
setSeries(NEW_SERIES_KEY, {
...series,
reportDefinitions: { ...rtd },
});
} else {
setSeries(NEW_SERIES_KEY, {
...series,
reportDefinitions: { ...rtd, [field]: value },
});
}
};

const onRemove = (field: string) => {
delete rtd[field];
setSeries(NEW_SERIES_KEY, {
...series,
reportDefinitions: rtd,
});
};

return (
<EuiFlexGroup direction="column" gutterSize="s">
{indexPattern &&
reportDefinitions.map(({ field, custom, options, defaultValue }) => (
<EuiFlexItem key={field}>
{!custom ? (
<EuiFlexGroup justifyContent="flexStart" gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<FieldValueSuggestions
label={labels[field]}
sourceField={field}
indexPattern={indexPattern}
value={rtd?.[field]}
onChange={(val?: string) => onChange(field, val)}
filters={(filters ?? []).map(({ query }) => query)}
time={series.time}
width={200}
/>
</EuiFlexItem>
{rtd?.[field] && (
<EuiFlexItem grow={false}>
<EuiBadge
iconSide="right"
iconType="cross"
color="hollow"
onClick={() => onRemove(field)}
iconOnClick={() => onRemove(field)}
iconOnClickAriaLabel={'Click to remove'}
onClickAriaLabel={'Click to remove'}
>
{rtd?.[field]}
</EuiBadge>
</EuiFlexItem>
)}
</EuiFlexGroup>
) : (
<CustomReportField
field={field}
options={options}
defaultValue={defaultValue}
seriesId={NEW_SERIES_KEY}
/>
)}
</EuiFlexItem>
))}
</EuiFlexGroup>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { screen } from '@testing-library/react';
import { render } from '../../../../../utils/test_helper';
import { ReportFilters } from './report_filters';
import { getDefaultConfigs } from '../../configurations/default_configs';
import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers';
import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';

describe('Series Builder ReportFilters', function () {
const dataViewSeries = getDefaultConfigs({
reportType: 'pld',
indexPattern: mockIndexPattern,
seriesId: NEW_SERIES_KEY,
});
mockUrlStorage({});
it('should render properly', function () {
render(<ReportFilters dataViewSeries={dataViewSeries} />);

screen.getByText('Add filter');
shahzad31 marked this conversation as resolved.
Show resolved Hide resolved
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { SeriesFilter } from '../../series_editor/columns/series_filter';
import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
import { DataSeries } from '../../types';

export function ReportFilters({ dataViewSeries }: { dataViewSeries: DataSeries }) {
return (
<SeriesFilter
series={dataViewSeries}
defaultFilters={dataViewSeries.defaultFilters}
seriesId={NEW_SERIES_KEY}
isNew={true}
/>
);
}
Original file line number Diff line number Diff line change
@@ -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 React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { mockUrlStorage, render } from '../../rtl_helpers';
import { ReportTypesCol, SELECTED_DATA_TYPE_FOR_REPORT } from './report_types_col';
import { ReportTypes } from '../series_builder';

describe('ReportTypesCol', function () {
it('should render properly', function () {
render(<ReportTypesCol reportTypes={ReportTypes.rum} />);
screen.getByText('Performance distribution');
screen.getByText('KPI over time');
});

it('should display empty message', function () {
render(<ReportTypesCol reportTypes={[]} />);
screen.getByText(SELECTED_DATA_TYPE_FOR_REPORT);
});

it('should set series on change', function () {
const { setSeries } = mockUrlStorage({});
render(<ReportTypesCol reportTypes={ReportTypes.synthetics} />);

fireEvent.click(screen.getByText(/monitor duration/i));

expect(setSeries).toHaveBeenCalledWith('newSeriesKey', {
breakdown: 'user_agent.name',
reportDefinitions: {},
reportType: 'upd',
time: { from: 'now-15m', to: 'now' },
});
expect(setSeries).toHaveBeenCalledTimes(1);
});

it('should set selected as filled', function () {
const { setSeries } = mockUrlStorage({
data: {
newSeriesKey: {
dataType: 'synthetics',
reportType: 'upp',
breakdown: 'monitor.status',
time: { from: 'now-15m', to: 'now' },
},
},
});

render(<ReportTypesCol reportTypes={ReportTypes.synthetics} />);

const button = screen.getByRole('button', {
name: /pings histogram/i,
});

expect(button.classList).toContain('euiButton--fill');
fireEvent.click(button);

// undefined on click selected
expect(setSeries).toHaveBeenCalledWith('newSeriesKey', { dataType: 'synthetics' });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { ReportViewTypeId, SeriesUrl } from '../../types';
import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_strorage';

interface Props {
reportTypes: Array<{ id: ReportViewTypeId; label: string }>;
}

export function ReportTypesCol({ reportTypes }: Props) {
const {
series: { reportType: selectedReportType, ...restSeries },
setSeries,
} = useUrlStorage(NEW_SERIES_KEY);

return reportTypes?.length > 0 ? (
<EuiFlexGroup direction="column" gutterSize="xs">
{reportTypes.map(({ id: reportType, label }) => (
<EuiFlexItem key={reportType}>
<EuiButton
size="s"
iconSide="right"
iconType="arrowRight"
color={selectedReportType === reportType ? 'primary' : 'text'}
fill={selectedReportType === reportType}
onClick={() => {
if (reportType === selectedReportType) {
setSeries(NEW_SERIES_KEY, {
dataType: restSeries.dataType,
} as SeriesUrl);
} else {
setSeries(NEW_SERIES_KEY, {
...restSeries,
reportType,
reportDefinitions: {},
});
}
}}
>
{label}
</EuiButton>
</EuiFlexItem>
))}
</EuiFlexGroup>
) : (
<EuiText color="subdued">{SELECTED_DATA_TYPE_FOR_REPORT}</EuiText>
);
}

export const SELECTED_DATA_TYPE_FOR_REPORT = i18n.translate(
'xpack.observability.expView.reportType.noDataType',
{ defaultMessage: 'Select a data type to start building a series.' }
);
Original file line number Diff line number Diff line change
@@ -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 React from 'react';
import { EuiSuperSelect } from '@elastic/eui';
import { useUrlStorage } from '../hooks/use_url_strorage';
import { ReportDefinition } from '../types';

interface Props {
field: string;
seriesId: string;
defaultValue?: string;
options: ReportDefinition['options'];
}

export function CustomReportField({ field, seriesId, options: opts, defaultValue }: Props) {
const { series, setSeries } = useUrlStorage(seriesId);

const { reportDefinitions: rtd = {} } = series;

const onChange = (value: string) => {
setSeries(seriesId, { ...series, reportDefinitions: { ...rtd, [field]: value } });
};

const { reportDefinitions } = series;

const NO_SELECT = 'no_select';

const options = [{ label: 'Select metric', field: NO_SELECT }, ...(opts ?? [])];

return (
<div style={{ maxWidth: 200 }}>
<EuiSuperSelect
options={options.map(({ label, field: fd, description }) => ({
value: fd,
inputDisplay: label,
}))}
valueOfSelected={reportDefinitions?.[field] || defaultValue || NO_SELECT}
onChange={(value) => onChange(value)}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React, { useState } from 'react';

import { i18n } from '@kbn/i18n';
import { EuiButton, EuiBasicTable, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { AppDataType, ReportViewTypeId, ReportViewTypes, SeriesUrl } from '../types';
import { DataTypesCol } from './columns/data_types_col';
import { ReportTypesCol } from './columns/report_types_col';
import { ReportDefinitionCol } from './columns/report_definition_col';
import { ReportFilters } from './columns/report_filters';
import { ReportBreakdowns } from './columns/report_breakdowns';
import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_strorage';
import { useIndexPatternContext } from '../hooks/use_default_index_pattern';
import { getDefaultConfigs } from '../configurations/default_configs';

export const ReportTypes: Record<AppDataType, Array<{ id: ReportViewTypeId; label: string }>> = {
synthetics: [
{ id: 'upd', label: 'Monitor duration' },
{ id: 'upp', label: 'Pings histogram' },
],
rum: [
{ id: 'pld', label: 'Performance distribution' },
{ id: 'kpi', label: 'KPI over time' },
],
apm: [
{ id: 'svl', label: 'Latency' },
{ id: 'tpt', label: 'Throughput' },
],
logs: [
{
id: 'logs',
label: 'Logs Frequency',
},
],
metrics: [
{ id: 'cpu', label: 'CPU usage' },
{ id: 'mem', label: 'Memory usage' },
{ id: 'nwk', label: 'Network activity' },
],
};

export function SeriesBuilder() {
const { series, setSeries, allSeriesIds, removeSeries } = useUrlStorage(NEW_SERIES_KEY);

const { dataType, reportType, reportDefinitions = {}, filters = [] } = series;

const [isFlyoutVisible, setIsFlyoutVisible] = useState(!!series.dataType);

const { indexPattern } = useIndexPatternContext();

const getDataViewSeries = () => {
return getDefaultConfigs({
indexPattern,
reportType: reportType!,
seriesId: NEW_SERIES_KEY,
});
};

const columns = [
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.dataType', {
defaultMessage: 'Data Type',
}),
width: '20%',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These don't all add up to 100%. Are they meant to wrap?

render: (val: string) => <DataTypesCol />,
},
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.report', {
defaultMessage: 'Report',
}),
width: '20%',
render: (val: string) => (
<ReportTypesCol reportTypes={dataType ? ReportTypes[dataType] : []} />
),
},
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.definition', {
defaultMessage: 'Definition',
}),
width: '30%',
render: (val: string) =>
reportType && indexPattern ? (
<ReportDefinitionCol dataViewSeries={getDataViewSeries()} />
) : null,
},
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.filters', {
defaultMessage: 'Filters',
}),
width: '25%',
render: (val: string) =>
reportType && indexPattern ? <ReportFilters dataViewSeries={getDataViewSeries()} /> : null,
},
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.breakdown', {
defaultMessage: 'Breakdowns',
}),
width: '25%',
field: 'id',
render: (val: string) =>
reportType && indexPattern ? (
<ReportBreakdowns dataViewSeries={getDataViewSeries()} />
) : null,
},
];

const addSeries = () => {
if (reportType) {
const newSeriesId = `${
reportDefinitions?.['service.name'] ||
reportDefinitions?.['monitor.id'] ||
ReportViewTypes[reportType]
}`;

const newSeriesN = {
reportType,
time: { from: 'now-30m', to: 'now' },
filters,
reportDefinitions,
} as SeriesUrl;

setSeries(newSeriesId, newSeriesN).then(() => {
removeSeries(NEW_SERIES_KEY);
setIsFlyoutVisible(false);
});
}
};

const items = [{ id: NEW_SERIES_KEY }];

let flyout;

if (isFlyoutVisible) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe you need this conditional. It appears as though the parent this is wrapped in already contains this conditional.

flyout = (
<BottomFlyout aria-labelledby="flyoutTitle">
<EuiBasicTable
items={items as any}
columns={columns}
cellProps={{ style: { borderRight: '1px solid #d3dae6' } }}
/>
<EuiSpacer />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton fill iconType="plus" color="primary" onClick={addSeries}>
{i18n.translate('xpack.observability.expView.seriesBuilder.add', {
defaultMessage: 'Add',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
iconType="cross"
color="text"
onClick={() => {
removeSeries(NEW_SERIES_KEY);
setIsFlyoutVisible(false);
}}
>
{i18n.translate('xpack.observability.expView.seriesBuilder.cancel', {
defaultMessage: 'Cancel',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</BottomFlyout>
);
}

return (
<div>
{!isFlyoutVisible && (
<>
<EuiButton
iconType={isFlyoutVisible ? 'arrowDown' : 'arrowRight'}
color="primary"
iconSide="right"
onClick={() => setIsFlyoutVisible((prevState) => !prevState)}
disabled={allSeriesIds.length > 0}
>
{i18n.translate('xpack.observability.expView.seriesBuilder.addSeries', {
defaultMessage: 'Add series',
})}
</EuiButton>
<EuiSpacer />
</>
)}
{flyout}
</div>
);
}

const BottomFlyout = styled.div`
height: 300px;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { EuiSuperDatePicker } from '@elastic/eui';
import React, { useEffect } from 'react';
import { useHasData } from '../../../../hooks/use_has_data';
import { useUrlStorage } from '../hooks/use_url_strorage';
import { useQuickTimeRanges } from '../../../../hooks/use_quick_time_ranges';

export interface TimePickerTime {
from: string;
to: string;
}

export interface TimePickerQuickRange extends TimePickerTime {
display: string;
}

interface Props {
seriesId: string;
}

export function SeriesDatePicker({ seriesId }: Props) {
const { onRefreshTimeRange } = useHasData();

const commonlyUsedRanges = useQuickTimeRanges();

const { series, setSeries } = useUrlStorage(seriesId);

function onTimeChange({ start, end }: { start: string; end: string }) {
onRefreshTimeRange();
setSeries(seriesId, { ...series, time: { from: start, to: end } });
}

useEffect(() => {
if (!series || !series.time) {
setSeries(seriesId, { ...series, time: { from: 'now-5h', to: 'now' } });
}
}, [seriesId, series, setSeries]);

return (
<EuiSuperDatePicker
start={series?.time?.from}
end={series?.time?.to}
onTimeChange={onTimeChange}
commonlyUsedRanges={commonlyUsedRanges}
onRefresh={onTimeChange}
showUpdateButton={false}
/>
);
}
Original file line number Diff line number Diff line change
@@ -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 React from 'react';
import { mockUrlStorage, mockUseHasData, render } from '../rtl_helpers';
import { fireEvent, waitFor } from '@testing-library/react';
import { SeriesDatePicker } from './index';

describe('SeriesDatePicker', function () {
it('should render properly', function () {
mockUrlStorage({
data: {
'uptime-pings-histogram': {
reportType: 'upp',
breakdown: 'monitor.status',
time: { from: 'now-30m', to: 'now' },
},
},
});
const { getByText } = render(<SeriesDatePicker seriesId={'series-id'} />);

getByText('Last 30 minutes');
});

it('should set defaults', async function () {
const { setSeries: setSeries1 } = mockUrlStorage({
data: {
'uptime-pings-histogram': {
reportType: 'upp',
breakdown: 'monitor.status',
},
},
} as any);
render(<SeriesDatePicker seriesId={'uptime-pings-histogram'} />);
expect(setSeries1).toHaveBeenCalledTimes(1);
expect(setSeries1).toHaveBeenCalledWith('uptime-pings-histogram', {
breakdown: 'monitor.status',
reportType: 'upp',
time: { from: 'now-5h', to: 'now' },
});
});

it('should set series data', async function () {
const { setSeries } = mockUrlStorage({
data: {
'uptime-pings-histogram': {
reportType: 'upp',
breakdown: 'monitor.status',
time: { from: 'now-30m', to: 'now' },
},
},
});

const { onRefreshTimeRange } = mockUseHasData();
const { getByTestId } = render(<SeriesDatePicker seriesId={'series-id'} />);

await waitFor(function () {
fireEvent.click(getByTestId('superDatePickerToggleQuickMenuButton'));
});

fireEvent.click(getByTestId('superDatePickerCommonlyUsed_Today'));

expect(onRefreshTimeRange).toHaveBeenCalledTimes(1);

expect(setSeries).toHaveBeenCalledWith('series-id', {
breakdown: 'monitor.status',
reportType: 'upp',
time: { from: 'now/d', to: 'now/d' },
});
expect(setSeries).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { DataSeries } from '../../types';
import { SeriesChartTypes } from './chart_types';
import { MetricSelection } from './metric_selection';

interface Props {
series: DataSeries;
}

export function ActionsCol({ series }: Props) {
return (
<EuiFlexGroup direction="row" gutterSize="s" justifyContent="center">
<EuiFlexItem grow={false}>
<SeriesChartTypes seriesId={series.id} defaultChartType={series.seriesTypes[0]} />
</EuiFlexItem>
{series.hasMetricType && (
<EuiFlexItem grow={false}>
<MetricSelection seriesId={series.id} isDisabled={!series.hasMetricType} />
</EuiFlexItem>
)}
</EuiFlexGroup>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { Breakdowns } from './breakdowns';
import { mockIndexPattern, mockUrlStorage, render } from '../../rtl_helpers';
import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
import { getDefaultConfigs } from '../../configurations/default_configs';
import { USER_AGENT_OS } from '../../configurations/data/elasticsearch_fieldnames';

describe('Breakdowns', function () {
const dataViewSeries = getDefaultConfigs({
reportType: 'pld',
indexPattern: mockIndexPattern,
seriesId: NEW_SERIES_KEY,
});

it('should render properly', async function () {
mockUrlStorage({});

render(<Breakdowns seriesId={'series-id'} breakdowns={dataViewSeries.breakdowns} />);

screen.getAllByText('Browser family');
});

it('should call set series on change', function () {
const { setSeries } = mockUrlStorage({ breakdown: USER_AGENT_OS });

render(<Breakdowns seriesId={'series-id'} breakdowns={dataViewSeries.breakdowns} />);

screen.getAllByText('Operating system');

fireEvent.click(screen.getByTestId('seriesBreakdown'));

fireEvent.click(screen.getByText('Browser family'));

expect(setSeries).toHaveBeenCalledWith('series-id', {
breakdown: 'user_agent.name',
reportType: 'pld',
time: { from: 'now-15m', to: 'now' },
});
expect(setSeries).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -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 React from 'react';
import { EuiSuperSelect } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FieldLabels } from '../../configurations/constants';
import { useUrlStorage } from '../../hooks/use_url_strorage';

interface Props {
seriesId: string;
breakdowns: string[];
}

export function Breakdowns({ seriesId, breakdowns = [] }: Props) {
const { setSeries, series } = useUrlStorage(seriesId);

const selectedBreakdown = series.breakdown;
const NO_BREAKDOWN = 'no_breakdown';

const onOptionChange = (optionId: string) => {
if (optionId === NO_BREAKDOWN) {
setSeries(seriesId, {
...series,
breakdown: undefined,
});
} else {
setSeries(seriesId, {
...series,
breakdown: selectedBreakdown === optionId ? undefined : optionId,
});
}
};

const items = breakdowns.map((breakdown) => ({ id: breakdown, label: FieldLabels[breakdown] }));
items.push({
shahzad31 marked this conversation as resolved.
Show resolved Hide resolved
id: NO_BREAKDOWN,
label: i18n.translate('xpack.observability.exp.breakDownFilter.noBreakdown', {
defaultMessage: 'No breakdown',
}),
});

const options = items.map(({ id, label }) => ({
inputDisplay: id === NO_BREAKDOWN ? label : <strong>{label}</strong>,
value: id,
dropdownDisplay: label,
}));

return (
<div style={{ width: 200 }}>
<EuiSuperSelect
fullWidth
compressed
options={options}
valueOfSelected={selectedBreakdown ?? NO_BREAKDOWN}
onChange={(value) => onOptionChange(value)}
data-test-subj={'seriesBreakdown'}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { SeriesChartTypes, XYChartTypes } from './chart_types';
import { mockUrlStorage, render } from '../../rtl_helpers';

describe('SeriesChartTypes', function () {
it('should render properly', async function () {
mockUrlStorage({});

render(<SeriesChartTypes seriesId={'series-id'} defaultChartType={'line'} />);

await waitFor(() => {
screen.getByText(/chart type/i);
});
});

it('should call set series on change', async function () {
const { setSeries } = mockUrlStorage({});

render(<SeriesChartTypes seriesId={'series-id'} defaultChartType={'line'} />);

await waitFor(() => {
screen.getByText(/chart type/i);
});

fireEvent.click(screen.getByText(/chart type/i));
fireEvent.click(screen.getByTestId('lnsXY_seriesType-bar_stacked'));

expect(setSeries).toHaveBeenNthCalledWith(1, 'performance-distribution', {
breakdown: 'user_agent.name',
reportType: 'pld',
seriesType: 'bar_stacked',
time: { from: 'now-15m', to: 'now' },
});
expect(setSeries).toHaveBeenCalledTimes(3);
screen.debug();
});

describe('XYChartTypes', function () {
it('should render properly', async function () {
mockUrlStorage({});

render(<XYChartTypes value={'line'} onChange={jest.fn()} label={'Chart type'} />);

await waitFor(() => {
screen.getByText(/chart type/i);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license 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,
EuiButtonGroup,
EuiButtonIcon,
EuiLoadingSpinner,
EuiPopover,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../../../plugin';
import { useFetcher } from '../../../../..';
import { useUrlStorage } from '../../hooks/use_url_strorage';
import { SeriesType } from '../../../../../../../lens/public';

export function SeriesChartTypes({
seriesId,
defaultChartType,
}: {
seriesId: string;
defaultChartType: SeriesType;
}) {
const { series, setSeries, allSeries } = useUrlStorage(seriesId);

const seriesType = series?.seriesType ?? defaultChartType;

const onChange = (value: SeriesType) => {
Object.keys(allSeries).forEach((seriesKey) => {
const seriesN = allSeries[seriesKey];

setSeries(seriesKey, { ...seriesN, seriesType: value });
});
};

return (
<XYChartTypes
onChange={onChange}
value={seriesType}
excludeChartTypes={['bar_percentage_stacked']}
label={i18n.translate('xpack.observability.expView.chartTypes.label', {
defaultMessage: 'Chart type',
})}
/>
);
}

export interface XYChartTypesProps {
onChange: (value: SeriesType) => void;
value: SeriesType;
label?: string;
includeChartTypes?: string[];
excludeChartTypes?: string[];
}

export function XYChartTypes({
onChange,
value,
label,
includeChartTypes,
excludeChartTypes,
}: XYChartTypesProps) {
const [isOpen, setIsOpen] = useState(false);

const {
services: { lens },
} = useKibana<ObservabilityPublicPluginsStart>();

const { data = [], loading } = useFetcher(() => lens.getXyVisTypes(), [lens]);

let vizTypes = data ?? [];

if ((excludeChartTypes ?? []).length > 0) {
vizTypes = vizTypes.filter(({ id }) => !excludeChartTypes?.includes(id));
}

if ((includeChartTypes ?? []).length > 0) {
vizTypes = vizTypes.filter(({ id }) => includeChartTypes?.includes(id));
}

return loading ? (
<EuiLoadingSpinner />
) : (
<EuiPopover
isOpen={isOpen}
anchorPosition="downCenter"
button={
label ? (
<EuiButton
size="s"
color="text"
iconType={vizTypes.find(({ id }) => id === value)?.icon}
onClick={() => {
setIsOpen((prevState) => !prevState);
}}
>
{label}
</EuiButton>
) : (
<EuiButtonIcon
aria-label={vizTypes.find(({ id }) => id === value)?.label}
iconType={vizTypes.find(({ id }) => id === value)?.icon!}
onClick={() => {
setIsOpen((prevState) => !prevState);
}}
/>
)
}
closePopover={() => setIsOpen(false)}
>
<ButtonGroup
isIconOnly
buttonSize="m"
legend={i18n.translate('xpack.observability.xyChart.chartTypeLegend', {
defaultMessage: 'Chart type',
})}
name="chartType"
className="eui-displayInlineBlock"
options={vizTypes.map((t) => ({
id: t.id,
label: t.label,
title: t.label,
iconType: t.icon || 'empty',
'data-test-subj': `lnsXY_seriesType-${t.id}`,
}))}
idSelected={value}
onChange={(valueN: string) => {
onChange(valueN as SeriesType);
}}
/>
</EuiPopover>
);
}

const ButtonGroup = styled(EuiButtonGroup)`
&&& {
.euiButtonGroupButton-isSelected {
background-color: #a5a9b1 !important;
}
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license 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 { SeriesDatePicker } from '../../series_date_picker';

interface Props {
seriesId: string;
}
export function DatePickerCol({ seriesId }: Props) {
return (
<div style={{ maxWidth: 300 }}>
<SeriesDatePicker seriesId={seriesId} />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { FilterExpanded } from './filter_expanded';
import { mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers';
import { USER_AGENT_NAME } from '../../configurations/data/elasticsearch_fieldnames';

describe('FilterExpanded', function () {
it('should render properly', async function () {
mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] });

render(
<FilterExpanded
seriesId={'series-id'}
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={jest.fn()}
/>
);

screen.getByText('Browser Family');
});
it('should call go back on click', async function () {
mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] });
const goBack = jest.fn();

render(
<FilterExpanded
seriesId={'series-id'}
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={goBack}
/>
);

fireEvent.click(screen.getByText('Browser Family'));

expect(goBack).toHaveBeenCalledTimes(1);
expect(goBack).toHaveBeenCalledWith();
});

it('should call useValuesList on load', async function () {
mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] });

const { spy } = mockUseValuesList(['Chrome', 'Firefox']);

const goBack = jest.fn();

render(
<FilterExpanded
seriesId={'series-id'}
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={goBack}
/>
);

expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toBeCalledWith(
expect.objectContaining({
time: { from: 'now-15m', to: 'now' },
sourceField: USER_AGENT_NAME,
})
);
});
it('should filter display values', async function () {
mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] });

mockUseValuesList(['Chrome', 'Firefox']);

render(
<FilterExpanded
seriesId={'series-id'}
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={jest.fn()}
/>
);

expect(screen.queryByText('Firefox')).toBeTruthy();

fireEvent.input(screen.getByRole('searchbox'), { target: { value: 'ch' } });

expect(screen.queryByText('Firefox')).toBeFalsy();
expect(screen.getByText('Chrome')).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React, { useState, Fragment } from 'react';
import {
EuiFieldSearch,
EuiSpacer,
EuiButtonEmpty,
EuiLoadingSpinner,
EuiFilterGroup,
} from '@elastic/eui';
import { useIndexPatternContext } from '../../hooks/use_default_index_pattern';
import { useUrlStorage } from '../../hooks/use_url_strorage';
import { UrlFilter } from '../../types';
import { FilterValueButton } from './filter_value_btn';
import { useValuesList } from '../../../../../hooks/use_values_list';

interface Props {
seriesId: string;
label: string;
field: string;
goBack: () => void;
nestedField?: string;
}

export function FilterExpanded({ seriesId, field, label, goBack, nestedField }: Props) {
const { indexPattern } = useIndexPatternContext();

const [value, setValue] = useState('');

const [isOpen, setIsOpen] = useState({ value: '', negate: false });

const { series } = useUrlStorage(seriesId);

const { values, loading } = useValuesList({
sourceField: field,
time: series.time,
indexPattern,
});

const filters = series?.filters ?? [];

const currFilter: UrlFilter | undefined = filters.find(({ field: fd }) => field === fd);

const displayValues = (values || []).filter((opt) =>
opt.toLowerCase().includes(value.toLowerCase())
);

return (
<>
<EuiButtonEmpty iconType="arrowLeft" color="text" onClick={() => goBack()}>
{label}
</EuiButtonEmpty>
<EuiFieldSearch
fullWidth
value={value}
onChange={(evt) => {
setValue(evt.target.value);
}}
/>
<EuiSpacer size="s" />
{loading && (
<div style={{ textAlign: 'center' }}>
<EuiLoadingSpinner />
</div>
)}
{displayValues.map((opt) => (
<Fragment key={opt}>
<EuiFilterGroup fullWidth={true} color="primary">
<FilterValueButton
field={field}
value={opt}
allSelectedValues={currFilter?.notValues}
negate={true}
nestedField={nestedField}
seriesId={seriesId}
isNestedOpen={isOpen}
setIsNestedOpen={setIsOpen}
/>
<FilterValueButton
field={field}
value={opt}
allSelectedValues={currFilter?.values}
nestedField={nestedField}
seriesId={seriesId}
negate={false}
isNestedOpen={isOpen}
setIsNestedOpen={setIsOpen}
/>
</EuiFilterGroup>
<EuiSpacer size="s" />
</Fragment>
))}
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { FilterValueButton } from './filter_value_btn';
import { mockUrlStorage, mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers';
import {
USER_AGENT_NAME,
USER_AGENT_VERSION,
} from '../../configurations/data/elasticsearch_fieldnames';

describe('FilterValueButton', function () {
it('should render properly', async function () {
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={false}
/>
);

screen.getByText('Chrome');
});

it('should render display negate state', async function () {
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={true}
/>
);

screen.getByText('Not Chrome');
screen.getByTitle('Not Chrome');
const btn = screen.getByRole('button');
expect(btn.classList).toContain('euiButtonEmpty--danger');
});

it('should call set filter on click', async function () {
const { setFilter, removeFilter } = mockUseSeriesFilter();

render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={true}
allSelectedValues={['Firefox']}
/>
);

fireEvent.click(screen.getByText('Not Chrome'));

expect(removeFilter).toHaveBeenCalledTimes(0);
expect(setFilter).toHaveBeenCalledTimes(1);

expect(setFilter).toHaveBeenCalledWith({
field: 'user_agent.name',
negate: true,
value: 'Chrome',
});
});
it('should remove filter on click if already selected', async function () {
mockUrlStorage({});
const { removeFilter } = mockUseSeriesFilter();

render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={false}
allSelectedValues={['Chrome', 'Firefox']}
/>
);

fireEvent.click(screen.getByText('Chrome'));

expect(removeFilter).toHaveBeenCalledWith({
field: 'user_agent.name',
negate: false,
value: 'Chrome',
});
});

it('should change filter on negated one', async function () {
const { removeFilter } = mockUseSeriesFilter();

render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={true}
allSelectedValues={['Chrome', 'Firefox']}
/>
);

fireEvent.click(screen.getByText('Not Chrome'));

expect(removeFilter).toHaveBeenCalledWith({
field: 'user_agent.name',
negate: true,
value: 'Chrome',
});
});

it('should force open nested', async function () {
mockUseSeriesFilter();
const { spy } = mockUseValuesList();

render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: 'Chrome', negate: false }}
setIsNestedOpen={jest.fn()}
negate={false}
allSelectedValues={['Chrome', 'Firefox']}
nestedField={USER_AGENT_VERSION}
/>
);

expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toBeCalledWith(
expect.objectContaining({
filters: [
{
term: {
[USER_AGENT_NAME]: 'Chrome',
},
},
],
sourceField: 'user_agent.version',
})
);
});
it('should set isNestedOpen on click', async function () {
mockUseSeriesFilter();
const { spy } = mockUseValuesList();

render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: 'Chrome', negate: false }}
setIsNestedOpen={jest.fn()}
negate={false}
allSelectedValues={['Chrome', 'Firefox']}
nestedField={USER_AGENT_VERSION}
/>
);

expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toBeCalledWith(
expect.objectContaining({
filters: [
{
term: {
[USER_AGENT_NAME]: 'Chrome',
},
},
],
sourceField: USER_AGENT_VERSION,
})
);
});

it('should set call setIsNestedOpen on click selected', async function () {
mockUseSeriesFilter();
mockUseValuesList();

const setIsNestedOpen = jest.fn();

render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={setIsNestedOpen}
negate={false}
allSelectedValues={['Chrome', 'Firefox']}
nestedField={USER_AGENT_VERSION}
/>
);

fireEvent.click(screen.getByText('Chrome'));

expect(setIsNestedOpen).toHaveBeenCalledTimes(1);
expect(setIsNestedOpen).toHaveBeenCalledWith({ negate: false, value: '' });
});

it('should set call setIsNestedOpen on click not selected', async function () {
mockUseSeriesFilter();
mockUseValuesList();

const setIsNestedOpen = jest.fn();

render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: true }}
setIsNestedOpen={setIsNestedOpen}
negate={true}
allSelectedValues={['Firefox']}
nestedField={USER_AGENT_VERSION}
/>
);

fireEvent.click(screen.getByText('Not Chrome'));

expect(setIsNestedOpen).toHaveBeenCalledTimes(1);
expect(setIsNestedOpen).toHaveBeenCalledWith({ negate: true, value: 'Chrome' });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import { EuiFilterButton, hexToRgb } from '@elastic/eui';
import { useIndexPatternContext } from '../../hooks/use_default_index_pattern';
import { useUrlStorage } from '../../hooks/use_url_strorage';
import { useSeriesFilters } from '../../hooks/use_series_filters';
import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common';
import FieldValueSuggestions from '../../../field_value_suggestions';

interface Props {
value: string;
field: string;
allSelectedValues?: string[];
negate: boolean;
nestedField?: string;
seriesId: string;
isNestedOpen: {
value: string;
negate: boolean;
};
setIsNestedOpen: (val: { value: string; negate: boolean }) => void;
}

export function FilterValueButton({
isNestedOpen,
setIsNestedOpen,
value,
field,
negate,
seriesId,
nestedField,
allSelectedValues,
}: Props) {
const { series } = useUrlStorage(seriesId);

const { indexPattern } = useIndexPatternContext();

const { setFilter, removeFilter } = useSeriesFilters({ seriesId });

const hasActiveFilters = (allSelectedValues ?? []).includes(value);

const button = (
<FilterButton
hasActiveFilters={hasActiveFilters}
color={negate ? 'danger' : 'primary'}
onClick={() => {
if (hasActiveFilters) {
removeFilter({ field, value, negate });
} else {
setFilter({ field, value, negate });
}
if (!hasActiveFilters) {
setIsNestedOpen({ value, negate });
} else {
setIsNestedOpen({ value: '', negate });
}
}}
>
{negate
? i18n.translate('xpack.observability.expView.filterValueButton.negate', {
defaultMessage: 'Not {value}',
values: { value },
})
: value}
</FilterButton>
);

const onNestedChange = (val?: string) => {
setFilter({ field: nestedField!, value: val! });
setIsNestedOpen({ value: '', negate });
};

const forceOpenNested = isNestedOpen?.value === value && isNestedOpen.negate === negate;

const filters = useMemo(() => {
return [
{
term: {
[field]: value,
},
},
];
}, [field, value]);

return nestedField && forceOpenNested ? (
<FieldValueSuggestions
button={button}
label={'Version'}
indexPattern={indexPattern}
sourceField={nestedField}
onChange={onNestedChange}
filters={filters}
forceOpen={forceOpenNested}
anchorPosition="rightCenter"
time={series.time}
/>
) : (
button
);
}

const FilterButton = euiStyled(EuiFilterButton)`
background-color: rgba(${(props) => {
const color = props.hasActiveFilters
? props.color === 'danger'
? hexToRgb(props.theme.eui.euiColorDanger)
: hexToRgb(props.theme.eui.euiColorPrimary)
: 'initial';
return `${color[0]}, ${color[1]}, ${color[2]}, 0.1`;
}});
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { mockUrlStorage, render } from '../../rtl_helpers';
import { MetricSelection } from './metric_selection';

describe('MetricSelection', function () {
it('should render properly', function () {
render(<MetricSelection seriesId={'series-id'} isDisabled={false} />);

screen.getByText('Average');
});

it('should display selected value', function () {
mockUrlStorage({
data: {
'performance-distribution': {
reportType: 'kpi',
metric: 'median',
time: { from: 'now-15m', to: 'now' },
},
},
});

render(<MetricSelection seriesId={'series-id'} isDisabled={false} />);

screen.getByText('Median');
});

it('should be disabled on disabled state', function () {
render(<MetricSelection seriesId={'series-id'} isDisabled={true} />);

const btn = screen.getByRole('button');

expect(btn.classList).toContain('euiButton-isDisabled');
});

it('should call set series on change', function () {
const { setSeries } = mockUrlStorage({
data: {
'performance-distribution': {
reportType: 'kpi',
metric: 'median',
time: { from: 'now-15m', to: 'now' },
},
},
});

render(<MetricSelection seriesId={'series-id'} isDisabled={false} />);

fireEvent.click(screen.getByText('Median'));

screen.getByText('Chart metric group');

fireEvent.click(screen.getByText('95th Percentile'));

expect(setSeries).toHaveBeenNthCalledWith(1, 'performance-distribution', {
metric: '95th',
reportType: 'kpi',
time: { from: 'now-15m', to: 'now' },
});
// FIXME This is a bug in EUI EuiButtonGroup calls on change multiple times
// This should be one https://github.com/elastic/eui/issues/4629
expect(setSeries).toHaveBeenCalledTimes(3);
});

it('should call set series on change for all series', function () {
const { setSeries } = mockUrlStorage({
data: {
'page-views': {
reportType: 'kpi',
metric: 'median',
time: { from: 'now-15m', to: 'now' },
},
'performance-distribution': {
reportType: 'kpi',
metric: 'median',
time: { from: 'now-15m', to: 'now' },
},
},
});

render(<MetricSelection seriesId={'series-id'} isDisabled={false} />);

fireEvent.click(screen.getByText('Median'));

screen.getByText('Chart metric group');

fireEvent.click(screen.getByText('95th Percentile'));

expect(setSeries).toHaveBeenNthCalledWith(1, 'page-views', {
metric: '95th',
reportType: 'kpi',
time: { from: 'now-15m', to: 'now' },
});

expect(setSeries).toHaveBeenNthCalledWith(2, 'performance-distribution', {
metric: '95th',
reportType: 'kpi',
time: { from: 'now-15m', to: 'now' },
});
// FIXME This is a bug in EUI EuiButtonGroup calls on change multiple times
// This should be one https://github.com/elastic/eui/issues/4629
expect(setSeries).toHaveBeenCalledTimes(6);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license 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 { i18n } from '@kbn/i18n';
import { EuiButton, EuiButtonGroup, EuiPopover } from '@elastic/eui';
import { useUrlStorage } from '../../hooks/use_url_strorage';
import { OperationType } from '../../../../../../../lens/public';

const toggleButtons = [
{
id: `avg`,
label: i18n.translate('xpack.observability.expView.metricsSelect.average', {
defaultMessage: 'Average',
}),
},
{
id: `median`,
label: i18n.translate('xpack.observability.expView.metricsSelect.median', {
defaultMessage: 'Median',
}),
},
{
id: `95th`,
label: i18n.translate('xpack.observability.expView.metricsSelect.9thPercentile', {
defaultMessage: '95th Percentile',
}),
},
{
id: `99th`,
label: i18n.translate('xpack.observability.expView.metricsSelect.99thPercentile', {
defaultMessage: '99th Percentile',
}),
},
];

export function MetricSelection({
seriesId,
isDisabled,
}: {
seriesId: string;
isDisabled: boolean;
}) {
const { series, setSeries, allSeries } = useUrlStorage(seriesId);

const [isOpen, setIsOpen] = useState(false);

const [toggleIdSelected, setToggleIdSelected] = useState(series?.metric ?? 'avg');

const onChange = (optionId: OperationType) => {
setToggleIdSelected(optionId);

Object.keys(allSeries).forEach((seriesKey) => {
const seriesN = allSeries[seriesKey];

setSeries(seriesKey, { ...seriesN, metric: optionId });
});
};
const button = (
<EuiButton
onClick={() => setIsOpen((prevState) => !prevState)}
size="s"
color="text"
isDisabled={isDisabled}
>
{toggleButtons.find(({ id }) => id === toggleIdSelected)!.label}
</EuiButton>
);

return (
<EuiPopover button={button} isOpen={isOpen} closePopover={() => setIsOpen(false)}>
<EuiButtonGroup
buttonSize="m"
color="primary"
legend="Chart metric group"
options={toggleButtons}
idSelected={toggleIdSelected}
onChange={(id) => onChange(id as OperationType)}
/>
</EuiPopover>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { i18n } from '@kbn/i18n';
import React from 'react';
import { EuiButtonIcon } from '@elastic/eui';
import { DataSeries } from '../../types';
import { useUrlStorage } from '../../hooks/use_url_strorage';

interface Props {
series: DataSeries;
}

export function RemoveSeries({ series }: Props) {
const { removeSeries } = useUrlStorage();

const onClick = () => {
removeSeries(series.id);
};
return (
<EuiButtonIcon
aria-label={i18n.translate('xpack.observability.expView.seriesEditor.removeSeries', {
defaultMessage: 'Click to remove series',
})}
iconType="cross"
color="primary"
onClick={onClick}
size="m"
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { i18n } from '@kbn/i18n';
import React, { useState, Fragment } from 'react';
import {
EuiButton,
EuiPopover,
EuiSpacer,
EuiButtonEmpty,
EuiFlexItem,
EuiFlexGroup,
} from '@elastic/eui';
import { FilterExpanded } from './filter_expanded';
import { DataSeries } from '../../types';
import { FieldLabels } from '../../configurations/constants';
import { SelectedFilters } from '../selected_filters';
import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_strorage';

interface Props {
seriesId: string;
defaultFilters: DataSeries['defaultFilters'];
series: DataSeries;
isNew?: boolean;
}

export interface Field {
label: string;
field: string;
nested?: string;
}

export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: Props) {
const [isPopoverVisible, setIsPopoverVisible] = useState(false);

const [selectedField, setSelectedField] = useState<Field | undefined>();

const options = defaultFilters.map((field) => {
if (typeof field === 'string') {
return { label: FieldLabels[field], field };
}
return { label: FieldLabels[field.field], field: field.field, nested: field.nested };
});
const disabled = seriesId === NEW_SERIES_KEY && !isNew;

const { setSeries, series: urlSeries } = useUrlStorage(seriesId);

const button = (
<EuiButtonEmpty
flush="left"
iconType="plus"
onClick={() => {
setIsPopoverVisible(true);
}}
isDisabled={disabled}
size="s"
>
{i18n.translate('xpack.observability.expView.seriesEditor.addFilter', {
defaultMessage: 'Add filter',
})}
</EuiButtonEmpty>
);

const mainPanel = (
<>
<EuiSpacer size="s" />
{options.map((opt) => (
<Fragment key={opt.label}>
<EuiButton
fullWidth={true}
iconType="arrowRight"
iconSide="right"
onClick={() => {
setSelectedField(opt);
}}
>
{opt.label}
</EuiButton>
<EuiSpacer size="s" />
</Fragment>
))}
</>
);

const childPanel = selectedField ? (
<FilterExpanded
seriesId={seriesId}
field={selectedField.field}
label={selectedField.label}
nestedField={selectedField.nested}
goBack={() => {
setSelectedField(undefined);
}}
/>
) : null;

const closePopover = () => {
setIsPopoverVisible(false);
setSelectedField(undefined);
};

return (
<EuiFlexGroup wrap direction="column" gutterSize="xs" alignItems="flexStart">
{!disabled && <SelectedFilters seriesId={seriesId} series={series} isNew={isNew} />}
<EuiFlexItem grow={false}>
<EuiPopover
button={button}
isOpen={isPopoverVisible}
closePopover={closePopover}
anchorPosition="leftCenter"
>
{!selectedField ? mainPanel : childPanel}
</EuiPopover>
</EuiFlexItem>
{(urlSeries.filters ?? []).length > 0 && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
flush="left"
color="text"
iconType="cross"
onClick={() => {
setSeries(seriesId, { ...urlSeries, filters: undefined });
}}
isDisabled={disabled}
size="s"
>
{i18n.translate('xpack.observability.expView.seriesEditor.clearFilter', {
defaultMessage: 'Clear filters',
})}
</EuiButtonEmpty>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
}
Original file line number Diff line number Diff line change
@@ -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 React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { mockIndexPattern, mockUrlStorage, render } from '../rtl_helpers';
import { SelectedFilters } from './selected_filters';
import { getDefaultConfigs } from '../configurations/default_configs';
import { NEW_SERIES_KEY } from '../hooks/use_url_strorage';
import { USER_AGENT_NAME } from '../configurations/data/elasticsearch_fieldnames';

describe('SelectedFilters', function () {
const dataViewSeries = getDefaultConfigs({
reportType: 'pld',
indexPattern: mockIndexPattern,
seriesId: NEW_SERIES_KEY,
});

it('should render properly', async function () {
mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] });

render(<SelectedFilters seriesId={'series-id'} series={dataViewSeries} />);

await waitFor(() => {
screen.getByText('Chrome');
screen.getByTitle('Filter: Browser family: Chrome. Select for more filter actions.');
});
});
});
Original file line number Diff line number Diff line change
@@ -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, { Fragment } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_strorage';
import { FilterLabel } from '../components/filter_label';
import { DataSeries, UrlFilter } from '../types';
import { useIndexPatternContext } from '../hooks/use_default_index_pattern';
import { useSeriesFilters } from '../hooks/use_series_filters';
import { getFiltersFromDefs } from '../hooks/use_lens_attributes';

interface Props {
seriesId: string;
series: DataSeries;
isNew?: boolean;
}
export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props) {
const { series } = useUrlStorage(seriesId);

const { reportDefinitions = {} } = series;

const { labels } = dataSeries;

const filters: UrlFilter[] = series.filters ?? [];

let definitionFilters: UrlFilter[] = getFiltersFromDefs(reportDefinitions, dataSeries);

// we don't want to display report definition filters in new series view
if (seriesId === NEW_SERIES_KEY && isNew) {
definitionFilters = [];
}

const { removeFilter } = useSeriesFilters({ seriesId });

const { indexPattern } = useIndexPatternContext();

return (filters.length > 0 || definitionFilters.length > 0) && indexPattern ? (
<EuiFlexItem>
<EuiFlexGroup wrap gutterSize="xs">
{filters.map(({ field, values, notValues }) => (
<Fragment key={field}>
{(values ?? []).map((val) => (
<EuiFlexItem key={field + val} grow={false}>
<FilterLabel
seriesId={seriesId}
field={field}
label={labels[field]}
value={val}
removeFilter={() => removeFilter({ field, value: val, negate: false })}
negate={false}
/>
</EuiFlexItem>
))}
{(notValues ?? []).map((val) => (
<EuiFlexItem key={field + val} grow={false}>
<FilterLabel
seriesId={seriesId}
field={field}
label={labels[field]}
value={val}
negate={true}
removeFilter={() => removeFilter({ field, value: val, negate: true })}
/>
</EuiFlexItem>
))}
</Fragment>
))}

{definitionFilters.map(({ field, values }) => (
<Fragment key={field}>
{(values ?? []).map((val) => (
<EuiFlexItem key={field + val} grow={false}>
<FilterLabel
seriesId={seriesId}
field={field}
label={labels[field]}
value={val}
removeFilter={() => {
// FIXME handle this use case
shahzad31 marked this conversation as resolved.
Show resolved Hide resolved
}}
negate={false}
definitionFilter={true}
/>
</EuiFlexItem>
))}
</Fragment>
))}
</EuiFlexGroup>
</EuiFlexItem>
) : null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiBasicTable, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui';
import { SeriesFilter } from './columns/series_filter';
import { ActionsCol } from './columns/actions_col';
import { Breakdowns } from './columns/breakdowns';
import { DataSeries } from '../types';
import { SeriesBuilder } from '../series_builder/series_builder';
import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_strorage';
import { getDefaultConfigs } from '../configurations/default_configs';
import { DatePickerCol } from './columns/date_picker_col';
import { RemoveSeries } from './columns/remove_series';
import { useIndexPatternContext } from '../hooks/use_default_index_pattern';

export function SeriesEditor() {
const { allSeries, firstSeriesId } = useUrlStorage();

const columns = [
{
name: i18n.translate('xpack.observability.expView.seriesEditor.name', {
defaultMessage: 'Name',
}),
field: 'id',
width: '15%',
render: (val: string) => (
<EuiText>
<EuiIcon type="dot" color="green" size="l" />{' '}
{val === NEW_SERIES_KEY ? 'new-series-preview' : val}
</EuiText>
),
},
...(firstSeriesId !== NEW_SERIES_KEY
? [
{
name: i18n.translate('xpack.observability.expView.seriesEditor.filters', {
defaultMessage: 'Filters',
}),
field: 'defaultFilters',
width: '25%',
render: (defaultFilters: string[], series: DataSeries) => (
<SeriesFilter defaultFilters={defaultFilters} seriesId={series.id} series={series} />
),
},
{
name: i18n.translate('xpack.observability.expView.seriesEditor.breakdowns', {
defaultMessage: 'Breakdowns',
}),
field: 'breakdowns',
width: '15%',
render: (val: string[], item: DataSeries) => (
<Breakdowns seriesId={item.id} breakdowns={val} />
),
},
{
name: '',
align: 'center' as const,
width: '15%',
field: 'id',
render: (val: string, item: DataSeries) => <ActionsCol series={item} />,
},
]
: []),
{
name: (
<div>
{i18n.translate('xpack.observability.expView.seriesEditor.time', {
defaultMessage: 'Time',
})}
</div>
),
width: '20%',
field: 'id',
align: 'right' as const,
render: (val: string, item: DataSeries) => <DatePickerCol seriesId={item.id} />,
},

...(firstSeriesId !== NEW_SERIES_KEY
? [
{
name: i18n.translate('xpack.observability.expView.seriesEditor.actions', {
defaultMessage: 'Actions',
}),
align: 'center' as const,
width: '5%',
field: 'id',
render: (val: string, item: DataSeries) => <RemoveSeries series={item} />,
},
]
: []),
];

const allSeriesKeys = Object.keys(allSeries);

const items: DataSeries[] = [];

const { indexPattern } = useIndexPatternContext();

allSeriesKeys.forEach((seriesKey) => {
const series = allSeries[seriesKey];
if (series.reportType && indexPattern) {
items.push(
getDefaultConfigs({
indexPattern,
reportType: series.reportType,
seriesId: seriesKey,
})
);
}
});

return (
<>
<EuiSpacer />
<EuiBasicTable
items={items}
rowHeader="firstName"
columns={columns}
rowProps={() => (firstSeriesId === NEW_SERIES_KEY ? {} : { height: 100 })}
noItemsMessage={i18n.translate('xpack.observability.expView.seriesEditor.notFound', {
defaultMessage: 'No series found, please add a series.',
})}
cellProps={{
style: {
verticalAlign: 'top',
},
}}
/>
<EuiSpacer />
<SeriesBuilder />
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { PaletteOutput } from 'src/plugins/charts/public';
import {
LastValueIndexPatternColumn,
DateHistogramIndexPatternColumn,
SeriesType,
OperationType,
IndexPatternColumn,
} from '../../../../../lens/public';

import { PersistableFilter } from '../../../../../lens/common';
import { IIndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns';

export const ReportViewTypes = {
pld: 'page-load-dist',
kpi: 'kpi-trends',
upd: 'uptime-duration',
upp: 'uptime-pings',
svl: 'service-latency',
tpt: 'service-throughput',
logs: 'logs-frequency',
cpu: 'cpu-usage',
mem: 'memory-usage',
nwk: 'network-activity',
} as const;

type ValueOf<T> = T[keyof T];

export type ReportViewTypeId = keyof typeof ReportViewTypes;

export type ReportViewType = ValueOf<typeof ReportViewTypes>;

export interface ReportDefinition {
field: string;
required?: boolean;
custom?: boolean;
defaultValue?: string;
options?: Array<{ field: string; label: string; description?: string }>;
}

export interface DataSeries {
reportType: ReportViewType;
id: string;
xAxisColumn: Partial<LastValueIndexPatternColumn> | Partial<DateHistogramIndexPatternColumn>;
yAxisColumn: Partial<IndexPatternColumn>;

breakdowns: string[];
defaultSeriesType: SeriesType;
defaultFilters: Array<string | { field: string; nested: string }>;
seriesTypes: SeriesType[];
filters?: PersistableFilter[];
reportDefinitions: ReportDefinition[];
labels: Record<string, string>;
hasMetricType: boolean;
palette?: PaletteOutput;
}

export interface SeriesUrl {
time: {
to: string;
from: string;
};
breakdown?: string;
filters?: UrlFilter[];
seriesType?: SeriesType;
reportType: ReportViewTypeId;
metric?: OperationType;
dataType?: AppDataType;
reportDefinitions?: Record<string, string>;
}

export interface UrlFilter {
field: string;
values?: string[];
notValues?: string[];
}

export interface ConfigProps {
seriesId: string;
indexPattern: IIndexPattern;
}

export type AppDataType = 'synthetics' | 'rum' | 'logs' | 'metrics' | 'apm';
Original file line number Diff line number Diff line change
@@ -15,14 +15,19 @@ import {
EuiSelectableOption,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { PopoverAnchorPosition } from '@elastic/eui/src/components/popover/popover';

export interface FieldValueSelectionProps {
value?: string;
label: string;
loading: boolean;
loading?: boolean;
onChange: (val?: string) => void;
values?: string[];
setQuery: Dispatch<SetStateAction<string>>;
anchorPosition?: PopoverAnchorPosition;
forceOpen?: boolean;
button?: JSX.Element;
width?: number;
}

const formatOptions = (values?: string[], value?: string): EuiSelectableOption[] => {
@@ -38,6 +43,10 @@ export function FieldValueSelection({
loading,
values,
setQuery,
button,
width,
forceOpen,
anchorPosition,
onChange: onSelectionChange,
}: FieldValueSelectionProps) {
const [options, setOptions] = useState<EuiSelectableOption[]>(formatOptions(values, value));
@@ -63,8 +72,9 @@ export function FieldValueSelection({
setQuery((evt.target as HTMLInputElement).value);
};

const button = (
const anchorButton = (
<EuiButton
style={width ? { width } : {}}
size="s"
iconType="arrowDown"
iconSide="right"
@@ -80,9 +90,10 @@ export function FieldValueSelection({
<EuiPopover
id="popover"
panelPaddingSize="none"
button={button}
isOpen={isPopoverOpen}
button={button || anchorButton}
isOpen={isPopoverOpen || forceOpen}
closePopover={closePopover}
anchorPosition={anchorPosition}
>
<EuiSelectable
searchable
Original file line number Diff line number Diff line change
@@ -8,29 +8,43 @@
import React, { useState } from 'react';

import { useDebounce } from 'react-use';
import { PopoverAnchorPosition } from '@elastic/eui/src/components/popover/popover';
import { useValuesList } from '../../../hooks/use_values_list';
import { IIndexPattern } from '../../../../../../../src/plugins/data/common';
import { IndexPattern } from '../../../../../../../src/plugins/data/common';
import { FieldValueSelection } from './field_value_selection';
import { ESFilter } from '../../../../../../../typings/elasticsearch';

export interface FieldValueSuggestionsProps {
value?: string;
label: string;
indexPattern: IIndexPattern;
indexPattern: IndexPattern;
sourceField: string;
onChange: (val?: string) => void;
filters: ESFilter[];
anchorPosition?: PopoverAnchorPosition;
time?: { from: string; to: string };
forceOpen?: boolean;
button?: JSX.Element;
width?: number;
}

export function FieldValueSuggestions({
sourceField,
label,
indexPattern,
value,
filters,
button,
time,
width,
forceOpen,
anchorPosition,
onChange: onSelectionChange,
}: FieldValueSuggestionsProps) {
const [query, setQuery] = useState('');
const [debouncedValue, setDebouncedValue] = useState('');

const { values, loading } = useValuesList({ indexPattern, query, sourceField });
const { values, loading } = useValuesList({ indexPattern, query, sourceField, filters, time });

useDebounce(
() => {
@@ -48,6 +62,10 @@ export function FieldValueSuggestions({
setQuery={setDebouncedValue}
loading={loading}
value={value}
button={button}
forceOpen={forceOpen}
anchorPosition={anchorPosition}
width={width}
/>
);
}
Original file line number Diff line number Diff line change
@@ -17,12 +17,19 @@ import { HasData, ObservabilityFetchDataPlugins } from '../typings/fetch_overvie
import { HasDataContextProvider } from './has_data_context';
import * as pluginContext from '../hooks/use_plugin_context';
import { PluginContextValue } from './plugin_context';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';

const relativeStart = '2020-10-08T06:00:00.000Z';
const relativeEnd = '2020-10-08T07:00:00.000Z';

function wrapper({ children }: { children: React.ReactElement }) {
return <HasDataContextProvider>{children}</HasDataContextProvider>;
const history = createMemoryHistory();
return (
<Router history={history}>
<HasDataContextProvider>{children}</HasDataContextProvider>
</Router>
);
}

function unregisterAll() {
46 changes: 25 additions & 21 deletions x-pack/plugins/observability/public/context/has_data_context.tsx
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@

import { uniqueId } from 'lodash';
import React, { createContext, useEffect, useState } from 'react';
import { useRouteMatch } from 'react-router-dom';
import { Alert } from '../../../alerting/common';
import { getDataHandler } from '../data_handler';
import { FETCH_STATUS } from '../hooks/use_fetcher';
@@ -41,35 +42,38 @@ export function HasDataContextProvider({ children }: { children: React.ReactNode

const [hasData, setHasData] = useState<HasDataContextValue['hasData']>({});

const isExploratoryView = useRouteMatch('/exploratory-view');

useEffect(
() => {
apps.forEach(async (app) => {
try {
if (app !== 'alert') {
const params =
app === 'ux'
? { absoluteTime: { start: absoluteStart, end: absoluteEnd } }
: undefined;

const result = await getDataHandler(app)?.hasData(params);
if (!isExploratoryView)
apps.forEach(async (app) => {
try {
if (app !== 'alert') {
const params =
app === 'ux'
? { absoluteTime: { start: absoluteStart, end: absoluteEnd } }
: undefined;

const result = await getDataHandler(app)?.hasData(params);
setHasData((prevState) => ({
...prevState,
[app]: {
hasData: result,
status: FETCH_STATUS.SUCCESS,
},
}));
}
} catch (e) {
setHasData((prevState) => ({
...prevState,
[app]: {
hasData: result,
status: FETCH_STATUS.SUCCESS,
hasData: undefined,
status: FETCH_STATUS.FAILURE,
},
}));
}
} catch (e) {
setHasData((prevState) => ({
...prevState,
[app]: {
hasData: undefined,
status: FETCH_STATUS.FAILURE,
},
}));
}
});
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
71 changes: 71 additions & 0 deletions x-pack/plugins/observability/public/hooks/use_breadcrumbs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { ChromeBreadcrumb } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { MouseEvent, useEffect } from 'react';
import { EuiBreadcrumb } from '@elastic/eui';
import { stringify } from 'query-string';
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
import { useQueryParams } from './use_query_params';

const EMPTY_QUERY = '?';

function handleBreadcrumbClick(
breadcrumbs: ChromeBreadcrumb[],
navigateToHref?: (url: string) => Promise<void>
) {
return breadcrumbs.map((bc) => ({
...bc,
...(bc.href
? {
onClick: (event: MouseEvent) => {
if (navigateToHref && bc.href) {
event.preventDefault();
navigateToHref(bc.href);
}
},
}
: {}),
}));
}

export const makeBaseBreadcrumb = (href: string, params?: any): EuiBreadcrumb => {
if (params) {
const crumbParams = { ...params };

delete crumbParams.statusFilter;
const query = stringify(crumbParams, { skipEmptyString: true, skipNull: true });
href += query === EMPTY_QUERY ? '' : query;
}
return {
text: i18n.translate('xpack.observability.breadcrumbs.observability', {
defaultMessage: 'Observability',
}),
href,
};
};

export const useBreadcrumbs = (extraCrumbs: ChromeBreadcrumb[]) => {
const params = useQueryParams();

const {
services: { chrome, application },
} = useKibana();

const setBreadcrumbs = chrome?.setBreadcrumbs;
const appPath = application?.getUrlForApp('observability-overview') ?? '';
const navigate = application?.navigateToUrl;

useEffect(() => {
if (setBreadcrumbs) {
setBreadcrumbs(
handleBreadcrumbClick([makeBaseBreadcrumb(appPath, params)].concat(extraCrumbs), navigate)
);
}
}, [appPath, extraCrumbs, navigate, params, setBreadcrumbs]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { useUiSetting } from '../../../../../src/plugins/kibana_react/public';
import { UI_SETTINGS } from '../../../../../src/plugins/data/common';
import { TimePickerQuickRange } from '../components/shared/exploratory_view/series_date_picker';

export function useQuickTimeRanges() {
const timePickerQuickRanges = useUiSetting<TimePickerQuickRange[]>(
UI_SETTINGS.TIMEPICKER_QUICK_RANGES
);

return timePickerQuickRanges.map(({ from, to, display }) => ({
start: from,
end: to,
label: display,
}));
}
46 changes: 36 additions & 10 deletions x-pack/plugins/observability/public/hooks/use_values_list.ts
Original file line number Diff line number Diff line change
@@ -5,32 +5,58 @@
* 2.0.
*/

import { IIndexPattern } from '../../../../../src/plugins/data/common';
import { IndexPattern } from '../../../../../src/plugins/data/common';
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
import { useFetcher } from './use_fetcher';
import { ESFilter } from '../../../../../typings/elasticsearch';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';

interface Props {
export interface Props {
sourceField: string;
query?: string;
indexPattern: IIndexPattern;
indexPattern: IndexPattern;
filters?: ESFilter[];
time?: { from: string; to: string };
}

export const useValuesList = ({ sourceField, indexPattern, query, filters }: Props) => {
export const useValuesList = ({
sourceField,
indexPattern,
query = '',
filters,
time,
}: Props): { values: string[]; loading?: boolean } => {
const {
services: { data },
} = useKibana<{ data: DataPublicPluginStart }>();

const { data: values, status } = useFetcher(() => {
const { from, to } = time ?? {};

const { data: values, loading } = useFetcher(() => {
if (!sourceField || !indexPattern) {
return [];
}
return data.autocomplete.getValueSuggestions({
indexPattern,
query: query || '',
field: indexPattern.fields.find(({ name }) => name === sourceField)!,
boolFilter: filters ?? [],
useTimeRange: !(from && to),
field: indexPattern.getFieldByName(sourceField)!,
boolFilter:
from && to
? [
...(filters || []),
{
range: {
'@timestamp': {
gte: from,
lte: to,
},
},
},
]
: filters || [],
});
}, [sourceField, query, data.autocomplete, indexPattern, filters]);
}, [query, sourceField, data.autocomplete, indexPattern, from, to, filters]);

return { values, loading: status === 'loading' || status === 'pending' };
return { values: values as string[], loading };
};
1 change: 1 addition & 0 deletions x-pack/plugins/observability/public/index.ts
Original file line number Diff line number Diff line change
@@ -55,3 +55,4 @@ export * from './typings';
export { useChartTheme } from './hooks/use_chart_theme';
export { useTheme } from './hooks/use_theme';
export { getApmTraceUrl } from './utils/get_apm_trace_url';
export { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils';
Loading