[Exploratory view] Refactor code for multi series (#101157)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Shahzad 2021-06-23 19:07:57 +02:00 committed by GitHub
parent 52d5b9d51d
commit 293dc95f8a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 1551 additions and 765 deletions

View file

@ -47,10 +47,11 @@ export function UXActionMenu({
const uxExploratoryViewLink = createExploratoryViewUrl(
{
'ux-series': {
'ux-series': ({
dataType: 'ux',
isNew: true,
time: { from: rangeFrom, to: rangeTo },
} as SeriesUrl,
} as unknown) as SeriesUrl,
},
http?.basePath.get()
);

View file

@ -89,7 +89,7 @@ export function PageLoadDistribution() {
{
[`${serviceName}-page-views`]: {
dataType: 'ux',
reportType: 'dist',
reportType: 'data-distribution',
time: { from: rangeFrom!, to: rangeTo! },
reportDefinitions: {
'service.name': serviceName as string[],

View file

@ -64,7 +64,7 @@ export function PageViewsTrend() {
{
[`${serviceName}-page-views`]: {
dataType: 'ux',
reportType: 'kpi',
reportType: 'kpi-over-time',
time: { from: rangeFrom!, to: rangeTo! },
reportDefinitions: {
'service.name': serviceName as string[],

View file

@ -38,6 +38,12 @@ export function EmptyView({
emptyMessage = SELECTED_DATA_TYPE_FOR_REPORT;
}
if (!series) {
emptyMessage = i18n.translate('xpack.observability.expView.seriesEditor.notFound', {
defaultMessage: 'No series found. Please add a series.',
});
}
return (
<Wrapper height={height}>
{loading && (
@ -77,7 +83,7 @@ export const EMPTY_LABEL = i18n.translate('xpack.observability.expView.seriesBui
export const CHOOSE_REPORT_DEFINITION = i18n.translate(
'xpack.observability.expView.seriesBuilder.emptyReportDefinition',
{
defaultMessage: 'Select a report type to create a visualization.',
defaultMessage: 'Select a report definition to create a visualization.',
}
);

View file

@ -29,6 +29,7 @@ describe('FilterLabel', function () {
negate={false}
seriesId={'kpi-over-time'}
removeFilter={jest.fn()}
indexPattern={mockIndexPattern}
/>
);
@ -52,6 +53,7 @@ describe('FilterLabel', function () {
negate={false}
seriesId={'kpi-over-time'}
removeFilter={removeFilter}
indexPattern={mockIndexPattern}
/>
);
@ -74,6 +76,7 @@ describe('FilterLabel', function () {
negate={false}
seriesId={'kpi-over-time'}
removeFilter={removeFilter}
indexPattern={mockIndexPattern}
/>
);
@ -99,6 +102,7 @@ describe('FilterLabel', function () {
negate={true}
seriesId={'kpi-over-time'}
removeFilter={jest.fn()}
indexPattern={mockIndexPattern}
/>
);

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern';
import { IndexPattern } from '../../../../../../../../src/plugins/data/public';
import { useSeriesFilters } from '../hooks/use_series_filters';
import { FilterValueLabel } from '../../filter_value_label/filter_value_label';
@ -17,6 +17,7 @@ interface Props {
seriesId: string;
negate: boolean;
definitionFilter?: boolean;
indexPattern: IndexPattern;
removeFilter: (field: string, value: string, notVal: boolean) => void;
}
@ -26,11 +27,10 @@ export function FilterLabel({
field,
value,
negate,
indexPattern,
removeFilter,
definitionFilter,
}: Props) {
const { indexPattern } = useAppIndexPatternContext();
const { invertFilter } = useSeriesFilters({ seriesId });
return indexPattern ? (

View file

@ -5,8 +5,15 @@
* 2.0.
*/
import { ReportViewTypeId } from '../../types';
import { CLS_FIELD, FCP_FIELD, FID_FIELD, LCP_FIELD, TBT_FIELD } from './elasticsearch_fieldnames';
import { ReportViewType } from '../../types';
import {
CLS_FIELD,
FCP_FIELD,
FID_FIELD,
LCP_FIELD,
TBT_FIELD,
TRANSACTION_TIME_TO_FIRST_BYTE,
} from './elasticsearch_fieldnames';
import {
AGENT_HOST_LABEL,
BROWSER_FAMILY_LABEL,
@ -58,6 +65,7 @@ export const FieldLabels: Record<string, string> = {
[TBT_FIELD]: TBT_LABEL,
[FID_FIELD]: FID_LABEL,
[CLS_FIELD]: CLS_LABEL,
[TRANSACTION_TIME_TO_FIRST_BYTE]: 'Page load time',
'monitor.id': MONITOR_ID_LABEL,
'monitor.status': MONITOR_STATUS_LABEL,
@ -77,11 +85,11 @@ export const FieldLabels: Record<string, string> = {
'http.request.method': REQUEST_METHOD,
};
export const DataViewLabels: Record<ReportViewTypeId, string> = {
dist: PERF_DIST_LABEL,
kpi: KPI_OVER_TIME_LABEL,
cwv: CORE_WEB_VITALS_LABEL,
mdd: DEVICE_DISTRIBUTION_LABEL,
export const DataViewLabels: Record<ReportViewType, string> = {
'data-distribution': PERF_DIST_LABEL,
'kpi-over-time': KPI_OVER_TIME_LABEL,
'core-web-vitals': CORE_WEB_VITALS_LABEL,
'device-data-distribution': DEVICE_DISTRIBUTION_LABEL,
};
export const USE_BREAK_DOWN_COLUMN = 'USE_BREAK_DOWN_COLUMN';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { AppDataType, ReportViewTypes } from '../types';
import { AppDataType, ReportViewType } from '../types';
import { getRumDistributionConfig } from './rum/data_distribution_config';
import { getSyntheticsDistributionConfig } from './synthetics/data_distribution_config';
import { getSyntheticsKPIConfig } from './synthetics/kpi_over_time_config';
@ -17,7 +17,7 @@ import { getMobileKPIDistributionConfig } from './mobile/distribution_config';
import { getMobileDeviceDistributionConfig } from './mobile/device_distribution_config';
interface Props {
reportType: keyof typeof ReportViewTypes;
reportType: ReportViewType;
indexPattern: IndexPattern;
dataType: AppDataType;
}
@ -25,23 +25,23 @@ interface Props {
export const getDefaultConfigs = ({ reportType, dataType, indexPattern }: Props) => {
switch (dataType) {
case 'ux':
if (reportType === 'dist') {
if (reportType === 'data-distribution') {
return getRumDistributionConfig({ indexPattern });
}
if (reportType === 'cwv') {
if (reportType === 'core-web-vitals') {
return getCoreWebVitalsConfig({ indexPattern });
}
return getKPITrendsLensConfig({ indexPattern });
case 'synthetics':
if (reportType === 'dist') {
if (reportType === 'data-distribution') {
return getSyntheticsDistributionConfig({ indexPattern });
}
return getSyntheticsKPIConfig({ indexPattern });
case 'mobile':
if (reportType === 'dist') {
if (reportType === 'data-distribution') {
return getMobileKPIDistributionConfig({ indexPattern });
}
if (reportType === 'mdd') {
if (reportType === 'device-data-distribution') {
return getMobileDeviceDistributionConfig({ indexPattern });
}
return getMobileKPIConfig({ indexPattern });

View file

@ -5,25 +5,37 @@
* 2.0.
*/
import { LensAttributes } from './lens_attributes';
import { LayerConfig, LensAttributes } from './lens_attributes';
import { mockAppIndexPattern, mockIndexPattern } from '../rtl_helpers';
import { getDefaultConfigs } from './default_configs';
import { sampleAttribute } from './test_data/sample_attribute';
import { LCP_FIELD, SERVICE_NAME, USER_AGENT_NAME } from './constants/elasticsearch_fieldnames';
import { LCP_FIELD, USER_AGENT_NAME } from './constants/elasticsearch_fieldnames';
import { buildExistsFilter, buildPhrasesFilter } from './utils';
describe('Lens Attribute', () => {
mockAppIndexPattern();
const reportViewConfig = getDefaultConfigs({
reportType: 'dist',
reportType: 'data-distribution',
dataType: 'ux',
indexPattern: mockIndexPattern,
});
reportViewConfig.filters?.push(...buildExistsFilter('transaction.type', mockIndexPattern));
let lnsAttr: LensAttributes;
const layerConfig: LayerConfig = {
reportConfig: reportViewConfig,
seriesType: 'line',
operationType: 'count',
indexPattern: mockIndexPattern,
reportDefinitions: {},
time: { from: 'now-15m', to: 'now' },
};
beforeEach(() => {
lnsAttr = new LensAttributes(mockIndexPattern, reportViewConfig, 'line', [], 'count', {});
lnsAttr = new LensAttributes([layerConfig]);
});
it('should return expected json', function () {
@ -31,7 +43,7 @@ describe('Lens Attribute', () => {
});
it('should return main y axis', function () {
expect(lnsAttr.getMainYAxis()).toEqual({
expect(lnsAttr.getMainYAxis(layerConfig)).toEqual({
dataType: 'number',
isBucketed: false,
label: 'Pages loaded',
@ -42,7 +54,7 @@ describe('Lens Attribute', () => {
});
it('should return expected field type', function () {
expect(JSON.stringify(lnsAttr.getFieldMeta('transaction.type'))).toEqual(
expect(JSON.stringify(lnsAttr.getFieldMeta('transaction.type', layerConfig))).toEqual(
JSON.stringify({
fieldMeta: {
count: 0,
@ -60,7 +72,7 @@ describe('Lens Attribute', () => {
});
it('should return expected field type for custom field with default value', function () {
expect(JSON.stringify(lnsAttr.getFieldMeta('performance.metric'))).toEqual(
expect(JSON.stringify(lnsAttr.getFieldMeta('performance.metric', layerConfig))).toEqual(
JSON.stringify({
fieldMeta: {
count: 0,
@ -79,11 +91,18 @@ describe('Lens Attribute', () => {
});
it('should return expected field type for custom field with passed value', function () {
lnsAttr = new LensAttributes(mockIndexPattern, reportViewConfig, 'line', [], 'count', {
'performance.metric': [LCP_FIELD],
});
const layerConfig1: LayerConfig = {
reportConfig: reportViewConfig,
seriesType: 'line',
operationType: 'count',
indexPattern: mockIndexPattern,
reportDefinitions: { 'performance.metric': [LCP_FIELD] },
time: { from: 'now-15m', to: 'now' },
};
expect(JSON.stringify(lnsAttr.getFieldMeta('performance.metric'))).toEqual(
lnsAttr = new LensAttributes([layerConfig1]);
expect(JSON.stringify(lnsAttr.getFieldMeta('performance.metric', layerConfig1))).toEqual(
JSON.stringify({
fieldMeta: {
count: 0,
@ -102,7 +121,7 @@ describe('Lens Attribute', () => {
});
it('should return expected number range column', function () {
expect(lnsAttr.getNumberRangeColumn('transaction.duration.us')).toEqual({
expect(lnsAttr.getNumberRangeColumn('transaction.duration.us', reportViewConfig)).toEqual({
dataType: 'number',
isBucketed: true,
label: 'Page load time',
@ -124,7 +143,7 @@ describe('Lens Attribute', () => {
});
it('should return expected number operation column', function () {
expect(lnsAttr.getNumberRangeColumn('transaction.duration.us')).toEqual({
expect(lnsAttr.getNumberRangeColumn('transaction.duration.us', reportViewConfig)).toEqual({
dataType: 'number',
isBucketed: true,
label: 'Page load time',
@ -160,7 +179,7 @@ describe('Lens Attribute', () => {
});
it('should return main x axis', function () {
expect(lnsAttr.getXAxis()).toEqual({
expect(lnsAttr.getXAxis(layerConfig, 'layer0')).toEqual({
dataType: 'number',
isBucketed: true,
label: 'Page load time',
@ -182,38 +201,45 @@ describe('Lens Attribute', () => {
});
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',
expect(lnsAttr.getLayers()).toEqual({
layer0: {
columnOrder: ['x-axis-column-layer0', 'y-axis-column-layer0'],
columns: {
'x-axis-column-layer0': {
dataType: 'number',
isBucketed: true,
label: 'Page load time',
operationType: 'range',
params: {
maxBars: 'auto',
ranges: [
{
from: 0,
label: '',
to: 1000,
},
],
type: 'histogram',
},
scale: 'interval',
sourceField: 'transaction.duration.us',
},
'y-axis-column-layer0': {
dataType: 'number',
isBucketed: false,
label: 'Pages loaded',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
filter: {
language: 'kuery',
query:
'transaction.type: page-load and processor.event: transaction and transaction.type : *',
},
},
scale: 'interval',
sourceField: 'transaction.duration.us',
},
'y-axis-column': {
dataType: 'number',
isBucketed: false,
label: 'Pages loaded',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
},
incompleteColumns: {},
},
incompleteColumns: {},
});
});
@ -225,12 +251,12 @@ describe('Lens Attribute', () => {
gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true },
layers: [
{
accessors: ['y-axis-column'],
layerId: 'layer1',
accessors: ['y-axis-column-layer0'],
layerId: 'layer0',
palette: undefined,
seriesType: 'line',
xAccessor: 'x-axis-column',
yConfig: [{ color: 'green', forAccessor: 'y-axis-column' }],
xAccessor: 'x-axis-column-layer0',
yConfig: [{ forAccessor: 'y-axis-column-layer0' }],
},
],
legend: { isVisible: true, position: 'right' },
@ -240,108 +266,52 @@ describe('Lens Attribute', () => {
});
});
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);
it('should return breakdown column', function () {
const layerConfig1: LayerConfig = {
reportConfig: reportViewConfig,
seriesType: 'line',
operationType: 'count',
indexPattern: mockIndexPattern,
reportDefinitions: { 'performance.metric': [LCP_FIELD] },
breakdown: USER_AGENT_NAME,
time: { from: 'now-15m', to: 'now' },
};
lnsAttr = new LensAttributes([layerConfig1]);
lnsAttr.getBreakdownColumn({
sourceField: USER_AGENT_NAME,
layerId: 'layer0',
indexPattern: mockIndexPattern,
});
expect(lnsAttr.visualization.layers).toEqual([
{
accessors: ['y-axis-column'],
layerId: 'layer1',
accessors: ['y-axis-column-layer0'],
layerId: 'layer0',
palette: undefined,
seriesType: 'line',
splitAccessor: 'break-down-column',
xAccessor: 'x-axis-column',
yConfig: [{ color: 'green', forAccessor: 'y-axis-column' }],
splitAccessor: 'breakdown-column-layer0',
xAccessor: 'x-axis-column-layer0',
yConfig: [{ forAccessor: 'y-axis-column-layer0' }],
},
]);
expect(lnsAttr.layers.layer1).toEqual({
columnOrder: ['x-axis-column', 'break-down-column', 'y-axis-column'],
expect(lnsAttr.layers.layer0).toEqual({
columnOrder: ['x-axis-column-layer0', 'breakdown-column-layer0', 'y-axis-column-layer0'],
columns: {
'break-down-column': {
'breakdown-column-layer0': {
dataType: 'string',
isBucketed: true,
label: 'Top values of Browser family',
operationType: 'terms',
params: {
missingBucket: false,
orderBy: { columnId: 'y-axis-column', type: 'column' },
orderBy: {
columnId: 'y-axis-column-layer0',
type: 'column',
},
orderDirection: 'desc',
otherBucket: true,
size: 10,
@ -349,10 +319,10 @@ describe('Lens Attribute', () => {
scale: 'ordinal',
sourceField: 'user_agent.name',
},
'x-axis-column': {
'x-axis-column-layer0': {
dataType: 'number',
isBucketed: true,
label: 'Page load time',
label: 'Largest contentful paint',
operationType: 'range',
params: {
maxBars: 'auto',
@ -360,62 +330,47 @@ describe('Lens Attribute', () => {
type: 'histogram',
},
scale: 'interval',
sourceField: 'transaction.duration.us',
sourceField: 'transaction.marks.agent.largestContentfulPaint',
},
'y-axis-column': {
'y-axis-column-layer0': {
dataType: 'number',
isBucketed: false,
label: 'Pages loaded',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
filter: {
language: 'kuery',
query:
'transaction.type: page-load and processor.event: transaction and transaction.type : *',
},
},
},
incompleteColumns: {},
});
});
});
it('should remove breakdown column', function () {
lnsAttr.addBreakdown(USER_AGENT_NAME);
describe('Layer Filters', function () {
it('should return expected filters', function () {
reportViewConfig.filters?.push(
...buildPhrasesFilter('service.name', ['elastic', 'kibana'], mockIndexPattern)
);
lnsAttr.removeBreakdown();
const layerConfig1: LayerConfig = {
reportConfig: reportViewConfig,
seriesType: 'line',
operationType: 'count',
indexPattern: mockIndexPattern,
reportDefinitions: { 'performance.metric': [LCP_FIELD] },
time: { from: 'now-15m', to: 'now' },
};
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' }],
},
]);
const filters = lnsAttr.getLayerFilters(layerConfig1, 2);
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',
},
});
expect(filters).toEqual(
'@timestamp >= now-15m and @timestamp <= now and transaction.type: page-load and processor.event: transaction and transaction.type : * and service.name: (elastic or kibana)'
);
});
});
});

View file

@ -27,13 +27,12 @@ import {
TermsIndexPatternColumn,
CardinalityIndexPatternColumn,
} from '../../../../../../lens/public';
import {
buildPhraseFilter,
buildPhrasesFilter,
IndexPattern,
} from '../../../../../../../../src/plugins/data/common';
import { urlFiltersToKueryString } from '../utils/stringify_kueries';
import { ExistsFilter, IndexPattern } from '../../../../../../../../src/plugins/data/common';
import { FieldLabels, FILTER_RECORDS, USE_BREAK_DOWN_COLUMN, TERMS_COLUMN } from './constants';
import { ColumnFilter, DataSeries, UrlFilter, URLReportDefinition } from '../types';
import { PersistableFilter } from '../../../../../../lens/common';
import { parseAbsoluteDate } from '../series_date_picker/date_range_picker';
function getLayerReferenceName(layerId: string) {
return `indexpattern-datasource-layer-${layerId}`;
@ -87,46 +86,50 @@ export const parseCustomFieldName = (
return { fieldName, columnType, columnFilters, timeScale, columnLabel };
};
export class LensAttributes {
export interface LayerConfig {
filters?: UrlFilter[];
reportConfig: DataSeries;
breakdown?: string;
seriesType?: SeriesType;
operationType?: OperationType;
reportDefinitions: URLReportDefinition;
time: { to: string; from: string };
indexPattern: IndexPattern;
}
export class LensAttributes {
layers: Record<string, PersistedIndexPatternLayer>;
visualization: XYState;
filters: UrlFilter[];
seriesType: SeriesType;
reportViewConfig: DataSeries;
reportDefinitions: URLReportDefinition;
breakdownSource?: string;
layerConfigs: LayerConfig[];
constructor(
indexPattern: IndexPattern,
reportViewConfig: DataSeries,
seriesType?: SeriesType,
filters?: UrlFilter[],
operationType?: OperationType,
reportDefinitions?: URLReportDefinition,
breakdownSource?: string
) {
this.indexPattern = indexPattern;
constructor(layerConfigs: LayerConfig[]) {
this.layers = {};
this.filters = filters ?? [];
this.reportDefinitions = reportDefinitions ?? {};
this.breakdownSource = breakdownSource;
if (operationType) {
reportViewConfig.yAxisColumns.forEach((yAxisColumn) => {
if (typeof yAxisColumn.operationType !== undefined) {
yAxisColumn.operationType = operationType as FieldBasedIndexPatternColumn['operationType'];
}
});
}
this.seriesType = seriesType ?? reportViewConfig.defaultSeriesType;
this.reportViewConfig = reportViewConfig;
this.layers.layer1 = this.getLayer();
layerConfigs.forEach(({ reportConfig, operationType }) => {
if (operationType) {
reportConfig.yAxisColumns.forEach((yAxisColumn) => {
if (typeof yAxisColumn.operationType !== undefined) {
yAxisColumn.operationType = operationType as FieldBasedIndexPatternColumn['operationType'];
}
});
}
});
this.layerConfigs = layerConfigs;
this.layers = this.getLayers();
this.visualization = this.getXyState();
}
getBreakdownColumn(sourceField: string): TermsIndexPatternColumn {
const fieldMeta = this.indexPattern.getFieldByName(sourceField);
getBreakdownColumn({
sourceField,
layerId,
indexPattern,
}: {
sourceField: string;
layerId: string;
indexPattern: IndexPattern;
}): TermsIndexPatternColumn {
const fieldMeta = indexPattern.getFieldByName(sourceField);
return {
sourceField,
@ -136,8 +139,8 @@ export class LensAttributes {
scale: 'ordinal',
isBucketed: true,
params: {
orderBy: { type: 'column', columnId: `y-axis-column-${layerId}` },
size: 10,
orderBy: { type: 'column', columnId: 'y-axis-column' },
orderDirection: 'desc',
otherBucket: true,
missingBucket: false,
@ -145,36 +148,14 @@ export class LensAttributes {
};
}
addBreakdown(sourceField: string) {
const { xAxisColumn } = this.reportViewConfig;
if (xAxisColumn?.sourceField === USE_BREAK_DOWN_COLUMN) {
// do nothing since this will be used a x axis source
return;
}
this.layers.layer1.columns['break-down-column'] = this.getBreakdownColumn(sourceField);
this.layers.layer1.columnOrder = [
'x-axis-column',
'break-down-column',
'y-axis-column',
...Object.keys(this.getChildYAxises()),
];
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;
}
getNumberRangeColumn(sourceField: string, label?: string): RangeIndexPatternColumn {
getNumberRangeColumn(
sourceField: string,
reportViewConfig: DataSeries,
label?: string
): RangeIndexPatternColumn {
return {
sourceField,
label: this.reportViewConfig.labels[sourceField] ?? label,
label: reportViewConfig.labels[sourceField] ?? label,
dataType: 'number',
operationType: 'range',
isBucketed: true,
@ -187,16 +168,36 @@ export class LensAttributes {
};
}
getCardinalityColumn(sourceField: string, label?: string) {
return this.getNumberOperationColumn(sourceField, 'unique_count', label);
getCardinalityColumn({
sourceField,
label,
reportViewConfig,
}: {
sourceField: string;
label?: string;
reportViewConfig: DataSeries;
}) {
return this.getNumberOperationColumn({
sourceField,
operationType: 'unique_count',
label,
reportViewConfig,
});
}
getNumberColumn(
sourceField: string,
columnType?: string,
operationType?: string,
label?: string
) {
getNumberColumn({
reportViewConfig,
label,
sourceField,
columnType,
operationType,
}: {
sourceField: string;
columnType?: string;
operationType?: string;
label?: string;
reportViewConfig: DataSeries;
}) {
if (columnType === 'operation' || operationType) {
if (
operationType === 'median' ||
@ -204,48 +205,58 @@ export class LensAttributes {
operationType === 'sum' ||
operationType === 'unique_count'
) {
return this.getNumberOperationColumn(sourceField, operationType, label);
return this.getNumberOperationColumn({
sourceField,
operationType,
label,
reportViewConfig,
});
}
if (operationType?.includes('th')) {
return this.getPercentileNumberColumn(sourceField, operationType);
return this.getPercentileNumberColumn(sourceField, operationType, reportViewConfig!);
}
}
return this.getNumberRangeColumn(sourceField, label);
return this.getNumberRangeColumn(sourceField, reportViewConfig!, label);
}
getNumberOperationColumn(
sourceField: string,
operationType: 'average' | 'median' | 'sum' | 'unique_count',
label?: string
):
getNumberOperationColumn({
sourceField,
label,
reportViewConfig,
operationType,
}: {
sourceField: string;
operationType: 'average' | 'median' | 'sum' | 'unique_count';
label?: string;
reportViewConfig: DataSeries;
}):
| AvgIndexPatternColumn
| MedianIndexPatternColumn
| SumIndexPatternColumn
| CardinalityIndexPatternColumn {
return {
...buildNumberColumn(sourceField),
label:
label ||
i18n.translate('xpack.observability.expView.columns.operation.label', {
defaultMessage: '{operationType} of {sourceField}',
values: {
sourceField: this.reportViewConfig.labels[sourceField],
operationType: capitalize(operationType),
},
}),
label: i18n.translate('xpack.observability.expView.columns.operation.label', {
defaultMessage: '{operationType} of {sourceField}',
values: {
sourceField: label || reportViewConfig.labels[sourceField],
operationType: capitalize(operationType),
},
}),
operationType,
};
}
getPercentileNumberColumn(
sourceField: string,
percentileValue: string
percentileValue: string,
reportViewConfig: DataSeries
): PercentileIndexPatternColumn {
return {
...buildNumberColumn(sourceField),
label: i18n.translate('xpack.observability.expView.columns.label', {
defaultMessage: '{percentileValue} percentile of {sourceField}',
values: { sourceField: this.reportViewConfig.labels[sourceField], percentileValue },
values: { sourceField: reportViewConfig.labels[sourceField], percentileValue },
}),
operationType: 'percentile',
params: { percentile: Number(percentileValue.split('th')[0]) },
@ -268,7 +279,7 @@ export class LensAttributes {
return {
operationType: 'terms',
sourceField,
label: label || 'Top values of ' + sourceField,
label: 'Top values of ' + label || sourceField,
dataType: 'string',
isBucketed: true,
scale: 'ordinal',
@ -283,30 +294,45 @@ export class LensAttributes {
};
}
getXAxis() {
const { xAxisColumn } = this.reportViewConfig;
getXAxis(layerConfig: LayerConfig, layerId: string) {
const { xAxisColumn } = layerConfig.reportConfig;
if (xAxisColumn?.sourceField === USE_BREAK_DOWN_COLUMN) {
return this.getBreakdownColumn(this.breakdownSource || this.reportViewConfig.breakdowns[0]);
return this.getBreakdownColumn({
layerId,
indexPattern: layerConfig.indexPattern,
sourceField: layerConfig.breakdown || layerConfig.reportConfig.breakdowns[0],
});
}
return this.getColumnBasedOnType(xAxisColumn.sourceField!, undefined, xAxisColumn.label);
return this.getColumnBasedOnType({
layerConfig,
label: xAxisColumn.label,
sourceField: xAxisColumn.sourceField!,
});
}
getColumnBasedOnType(
sourceField: string,
operationType?: OperationType,
label?: string,
colIndex?: number
) {
getColumnBasedOnType({
sourceField,
label,
layerConfig,
operationType,
colIndex,
}: {
sourceField: string;
operationType?: OperationType;
label?: string;
layerConfig: LayerConfig;
colIndex?: number;
}) {
const {
fieldMeta,
columnType,
fieldName,
columnFilters,
timeScale,
columnLabel,
} = this.getFieldMeta(sourceField);
timeScale,
columnFilters,
} = this.getFieldMeta(sourceField, layerConfig);
const { type: fieldType } = fieldMeta ?? {};
if (columnType === TERMS_COLUMN) {
@ -325,47 +351,76 @@ export class LensAttributes {
return this.getDateHistogramColumn(fieldName);
}
if (fieldType === 'number') {
return this.getNumberColumn(fieldName, columnType, operationType, columnLabel || label);
return this.getNumberColumn({
sourceField: fieldName,
columnType,
operationType,
label: columnLabel || label,
reportViewConfig: layerConfig.reportConfig,
});
}
if (operationType === 'unique_count') {
return this.getCardinalityColumn(fieldName, columnLabel || label);
return this.getCardinalityColumn({
sourceField: fieldName,
label: columnLabel || label,
reportViewConfig: layerConfig.reportConfig,
});
}
// FIXME review my approach again
return this.getDateHistogramColumn(fieldName);
}
getCustomFieldName(sourceField: string) {
return parseCustomFieldName(sourceField, this.reportViewConfig, this.reportDefinitions);
getCustomFieldName({
sourceField,
layerConfig,
}: {
sourceField: string;
layerConfig: LayerConfig;
}) {
return parseCustomFieldName(
sourceField,
layerConfig.reportConfig,
layerConfig.reportDefinitions
);
}
getFieldMeta(sourceField: string) {
getFieldMeta(sourceField: string, layerConfig: LayerConfig) {
const {
fieldName,
columnType,
columnLabel,
columnFilters,
timeScale,
columnLabel,
} = this.getCustomFieldName(sourceField);
} = this.getCustomFieldName({
sourceField,
layerConfig,
});
const fieldMeta = this.indexPattern.getFieldByName(fieldName);
const fieldMeta = layerConfig.indexPattern.getFieldByName(fieldName);
return { fieldMeta, fieldName, columnType, columnFilters, timeScale, columnLabel };
return { fieldMeta, fieldName, columnType, columnLabel, columnFilters, timeScale };
}
getMainYAxis() {
const { sourceField, operationType, label } = this.reportViewConfig.yAxisColumns[0];
getMainYAxis(layerConfig: LayerConfig) {
const { sourceField, operationType, label } = layerConfig.reportConfig.yAxisColumns[0];
if (sourceField === 'Records' || !sourceField) {
return this.getRecordsColumn(label);
}
return this.getColumnBasedOnType(sourceField!, operationType, label, 0);
return this.getColumnBasedOnType({
sourceField,
operationType,
label,
layerConfig,
colIndex: 0,
});
}
getChildYAxises() {
getChildYAxises(layerConfig: LayerConfig) {
const lensColumns: Record<string, FieldBasedIndexPatternColumn | SumIndexPatternColumn> = {};
const yAxisColumns = this.reportViewConfig.yAxisColumns;
const yAxisColumns = layerConfig.reportConfig.yAxisColumns;
// 1 means there is only main y axis
if (yAxisColumns.length === 1) {
return lensColumns;
@ -373,12 +428,13 @@ export class LensAttributes {
for (let i = 1; i < yAxisColumns.length; i++) {
const { sourceField, operationType, label } = yAxisColumns[i];
lensColumns[`y-axis-column-${i}`] = this.getColumnBasedOnType(
sourceField!,
lensColumns[`y-axis-column-${i}`] = this.getColumnBasedOnType({
sourceField: sourceField!,
operationType,
label,
i
);
layerConfig,
colIndex: i,
});
}
return lensColumns;
}
@ -396,20 +452,139 @@ export class LensAttributes {
scale: 'ratio',
sourceField: 'Records',
filter: columnFilter,
timeScale,
...(timeScale ? { timeScale } : {}),
} as CountIndexPatternColumn;
}
getLayer() {
return {
columnOrder: ['x-axis-column', 'y-axis-column', ...Object.keys(this.getChildYAxises())],
columns: {
'x-axis-column': this.getXAxis(),
'y-axis-column': this.getMainYAxis(),
...this.getChildYAxises(),
},
incompleteColumns: {},
};
getLayerFilters(layerConfig: LayerConfig, totalLayers: number) {
const {
filters,
time: { from, to },
reportConfig: { filters: layerFilters, reportType },
} = layerConfig;
let baseFilters = '';
if (reportType !== 'kpi-over-time' && totalLayers > 1) {
// for kpi over time, we don't need to add time range filters
// since those are essentially plotted along the x-axis
baseFilters += `@timestamp >= ${from} and @timestamp <= ${to}`;
}
layerFilters?.forEach((filter: PersistableFilter | ExistsFilter) => {
const qFilter = filter as PersistableFilter;
if (qFilter.query?.match_phrase) {
const fieldName = Object.keys(qFilter.query.match_phrase)[0];
const kql = `${fieldName}: ${qFilter.query.match_phrase[fieldName]}`;
if (baseFilters.length > 0) {
baseFilters += ` and ${kql}`;
} else {
baseFilters += kql;
}
}
if (qFilter.query?.bool?.should) {
const values: string[] = [];
let fieldName = '';
qFilter.query?.bool.should.forEach((ft: PersistableFilter['query']['match_phrase']) => {
if (ft.match_phrase) {
fieldName = Object.keys(ft.match_phrase)[0];
values.push(ft.match_phrase[fieldName]);
}
});
const kueryString = `${fieldName}: (${values.join(' or ')})`;
if (baseFilters.length > 0) {
baseFilters += ` and ${kueryString}`;
} else {
baseFilters += kueryString;
}
}
const existFilter = filter as ExistsFilter;
if (existFilter.exists) {
const fieldName = existFilter.exists.field;
const kql = `${fieldName} : *`;
if (baseFilters.length > 0) {
baseFilters += ` and ${kql}`;
} else {
baseFilters += kql;
}
}
});
const rFilters = urlFiltersToKueryString(filters ?? []);
if (!baseFilters) {
return rFilters;
}
if (!rFilters) {
return baseFilters;
}
return `${rFilters} and ${baseFilters}`;
}
getTimeShift(mainLayerConfig: LayerConfig, layerConfig: LayerConfig, index: number) {
if (index === 0 || mainLayerConfig.reportConfig.reportType !== 'kpi-over-time') {
return null;
}
const {
time: { from: mainFrom },
} = mainLayerConfig;
const {
time: { from },
} = layerConfig;
const inDays = parseAbsoluteDate(mainFrom).diff(parseAbsoluteDate(from), 'days');
if (inDays > 1) {
return inDays + 'd';
}
const inHours = parseAbsoluteDate(mainFrom).diff(parseAbsoluteDate(from), 'hours');
return inHours + 'h';
}
getLayers() {
const layers: Record<string, PersistedIndexPatternLayer> = {};
const layerConfigs = this.layerConfigs;
layerConfigs.forEach((layerConfig, index) => {
const { breakdown } = layerConfig;
const layerId = `layer${index}`;
const columnFilter = this.getLayerFilters(layerConfig, layerConfigs.length);
const timeShift = this.getTimeShift(this.layerConfigs[0], layerConfig, index);
const mainYAxis = this.getMainYAxis(layerConfig);
layers[layerId] = {
columnOrder: [
`x-axis-column-${layerId}`,
...(breakdown ? [`breakdown-column-${layerId}`] : []),
`y-axis-column-${layerId}`,
...Object.keys(this.getChildYAxises(layerConfig)),
],
columns: {
[`x-axis-column-${layerId}`]: this.getXAxis(layerConfig, layerId),
[`y-axis-column-${layerId}`]: {
...mainYAxis,
label: timeShift ? `${mainYAxis.label}(${timeShift})` : mainYAxis.label,
filter: { query: columnFilter, language: 'kuery' },
...(timeShift ? { timeShift } : {}),
},
...(breakdown && breakdown !== USE_BREAK_DOWN_COLUMN
? // do nothing since this will be used a x axis source
{
[`breakdown-column-${layerId}`]: this.getBreakdownColumn({
layerId,
sourceField: breakdown,
indexPattern: layerConfig.indexPattern,
}),
}
: {}),
...this.getChildYAxises(layerConfig),
},
incompleteColumns: {},
};
});
return layers;
}
getXyState(): XYState {
@ -422,71 +597,48 @@ export class LensAttributes {
tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true },
gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true },
preferredSeriesType: 'line',
layers: [
{
accessors: ['y-axis-column', ...Object.keys(this.getChildYAxises())],
layerId: 'layer1',
seriesType: this.seriesType ?? 'line',
palette: this.reportViewConfig.palette,
yConfig: this.reportViewConfig.yConfig || [
{ forAccessor: 'y-axis-column', color: 'green' },
],
xAccessor: 'x-axis-column',
},
],
...(this.reportViewConfig.yTitle ? { yTitle: this.reportViewConfig.yTitle } : {}),
layers: this.layerConfigs.map((layerConfig, index) => ({
accessors: [
`y-axis-column-layer${index}`,
...Object.keys(this.getChildYAxises(layerConfig)),
],
layerId: `layer${index}`,
seriesType: layerConfig.seriesType || layerConfig.reportConfig.defaultSeriesType,
palette: layerConfig.reportConfig.palette,
yConfig: layerConfig.reportConfig.yConfig || [
{ forAccessor: `y-axis-column-layer${index}` },
],
xAccessor: `x-axis-column-layer${index}`,
...(layerConfig.breakdown ? { splitAccessor: `breakdown-column-layer${index}` } : {}),
})),
...(this.layerConfigs[0].reportConfig.yTitle
? { yTitle: this.layerConfigs[0].reportConfig.yTitle }
: {}),
};
}
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;
}
parseFilters() {}
getJSON(): TypedLensByValueInput['attributes'] {
const uniqueIndexPatternsIds = Array.from(
new Set([...this.layerConfigs.map(({ indexPattern }) => indexPattern.id)])
);
return {
title: 'Prefilled from exploratory view app',
description: '',
visualizationType: 'lnsXY',
references: [
{
id: this.indexPattern.id!,
...uniqueIndexPatternsIds.map((patternId) => ({
id: patternId!,
name: 'indexpattern-datasource-current-indexpattern',
type: 'index-pattern',
},
{
id: this.indexPattern.id!,
name: getLayerReferenceName('layer1'),
})),
...this.layerConfigs.map(({ indexPattern }, index) => ({
id: indexPattern.id!,
name: getLayerReferenceName(`layer${index}`),
type: 'index-pattern',
},
})),
],
state: {
datasourceStates: {
@ -496,7 +648,7 @@ export class LensAttributes {
},
visualization: this.visualization,
query: { query: '', language: 'kuery' },
filters: this.parseFilters(),
filters: [],
},
};
}

View file

@ -14,7 +14,7 @@ import { MobileFields } from './mobile_fields';
export function getMobileDeviceDistributionConfig({ indexPattern }: ConfigProps): DataSeries {
return {
reportType: 'mobile-device-distribution',
reportType: 'device-data-distribution',
defaultSeriesType: 'bar',
seriesTypes: ['bar', 'bar_horizontal'],
xAxisColumn: {

View file

@ -10,10 +10,10 @@ import { FieldLabels, RECORDS_FIELD } from '../constants';
import { buildExistsFilter } from '../utils';
import { MONITORS_DURATION_LABEL, PINGS_LABEL } from '../constants/labels';
export function getSyntheticsDistributionConfig({ indexPattern }: ConfigProps): DataSeries {
export function getSyntheticsDistributionConfig({ series, indexPattern }: ConfigProps): DataSeries {
return {
reportType: 'data-distribution',
defaultSeriesType: 'line',
defaultSeriesType: series?.seriesType || 'line',
seriesTypes: [],
xAxisColumn: {
sourceField: 'performance.metric',

View file

@ -10,16 +10,16 @@ export const sampleAttribute = {
visualizationType: 'lnsXY',
references: [
{ id: 'apm-*', name: 'indexpattern-datasource-current-indexpattern', type: 'index-pattern' },
{ id: 'apm-*', name: 'indexpattern-datasource-layer-layer1', type: 'index-pattern' },
{ id: 'apm-*', name: 'indexpattern-datasource-layer-layer0', type: 'index-pattern' },
],
state: {
datasourceStates: {
indexpattern: {
layers: {
layer1: {
columnOrder: ['x-axis-column', 'y-axis-column'],
layer0: {
columnOrder: ['x-axis-column-layer0', 'y-axis-column-layer0'],
columns: {
'x-axis-column': {
'x-axis-column-layer0': {
sourceField: 'transaction.duration.us',
label: 'Page load time',
dataType: 'number',
@ -32,13 +32,18 @@ export const sampleAttribute = {
maxBars: 'auto',
},
},
'y-axis-column': {
'y-axis-column-layer0': {
dataType: 'number',
isBucketed: false,
label: 'Pages loaded',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
filter: {
language: 'kuery',
query:
'transaction.type: page-load and processor.event: transaction and transaction.type : *',
},
},
},
incompleteColumns: {},
@ -57,18 +62,15 @@ export const sampleAttribute = {
preferredSeriesType: 'line',
layers: [
{
accessors: ['y-axis-column'],
layerId: 'layer1',
accessors: ['y-axis-column-layer0'],
layerId: 'layer0',
seriesType: 'line',
yConfig: [{ forAccessor: 'y-axis-column', color: 'green' }],
xAccessor: 'x-axis-column',
yConfig: [{ forAccessor: 'y-axis-column-layer0' }],
xAccessor: 'x-axis-column-layer0',
},
],
},
query: { query: '', language: 'kuery' },
filters: [
{ meta: { index: 'apm-*' }, query: { match_phrase: { 'transaction.type': 'page-load' } } },
{ meta: { index: 'apm-*' }, query: { match_phrase: { 'processor.event': 'transaction' } } },
],
filters: [],
},
};

View file

@ -5,11 +5,12 @@
* 2.0.
*/
import rison, { RisonValue } from 'rison-node';
import type { SeriesUrl, UrlFilter } from '../types';
import type { AllSeries, AllShortSeries } from '../hooks/use_series_storage';
import type { SeriesUrl } from '../types';
import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns';
import { esFilters } from '../../../../../../../../src/plugins/data/public';
import { esFilters, ExistsFilter } from '../../../../../../../../src/plugins/data/public';
import { URL_KEYS } from './constants/url_constants';
import { PersistableFilter } from '../../../../../../lens/common';
export function convertToShortUrl(series: SeriesUrl) {
const {
@ -51,7 +52,7 @@ export function createExploratoryViewUrl(allSeries: AllSeries, baseHref = '') {
}
export function buildPhraseFilter(field: string, value: string, indexPattern: IIndexPattern) {
const fieldMeta = indexPattern.fields.find((fieldT) => fieldT.name === field);
const fieldMeta = indexPattern?.fields.find((fieldT) => fieldT.name === field);
if (fieldMeta) {
return [esFilters.buildPhraseFilter(fieldMeta, value, indexPattern)];
}
@ -59,7 +60,7 @@ export function buildPhraseFilter(field: string, value: string, indexPattern: II
}
export function buildPhrasesFilter(field: string, value: string[], indexPattern: IIndexPattern) {
const fieldMeta = indexPattern.fields.find((fieldT) => fieldT.name === field);
const fieldMeta = indexPattern?.fields.find((fieldT) => fieldT.name === field);
if (fieldMeta) {
return [esFilters.buildPhrasesFilter(fieldMeta, value, indexPattern)];
}
@ -67,9 +68,38 @@ export function buildPhrasesFilter(field: string, value: string[], indexPattern:
}
export function buildExistsFilter(field: string, indexPattern: IIndexPattern) {
const fieldMeta = indexPattern.fields.find((fieldT) => fieldT.name === field);
const fieldMeta = indexPattern?.fields.find((fieldT) => fieldT.name === field);
if (fieldMeta) {
return [esFilters.buildExistsFilter(fieldMeta, indexPattern)];
}
return [];
}
type FiltersType = PersistableFilter[] | ExistsFilter[];
export function urlFilterToPersistedFilter({
urlFilters,
initFilters,
indexPattern,
}: {
urlFilters: UrlFilter[];
initFilters: FiltersType;
indexPattern: IIndexPattern;
}) {
const parsedFilters: FiltersType = initFilters ? [...initFilters] : [];
urlFilters.forEach(({ field, values = [], notValues = [] }) => {
if (values?.length > 0) {
const filter = buildPhrasesFilter(field, values, indexPattern);
parsedFilters.push(...filter);
}
if (notValues?.length > 0) {
const filter = buildPhrasesFilter(field, notValues, indexPattern)[0];
filter.meta.negate = true;
parsedFilters.push(filter);
}
});
return parsedFilters;
}

View file

@ -51,8 +51,9 @@ describe('ExploratoryView', () => {
const initSeries = {
data: {
'ux-series': {
isNew: true,
dataType: 'ux' as const,
reportType: 'dist' as const,
reportType: 'data-distribution' as const,
breakdown: 'user_agent .name',
reportDefinitions: { 'service.name': ['elastic-co'] },
time: { from: 'now-15m', to: 'now' },

View file

@ -5,9 +5,10 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { EuiPanel, EuiTitle } from '@elastic/eui';
import styled from 'styled-components';
import { isEmpty } from 'lodash';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../plugin';
import { ExploratoryViewHeader } from './header/header';
@ -17,10 +18,37 @@ import { EmptyView } from './components/empty_view';
import { TypedLensByValueInput } from '../../../../../lens/public';
import { useAppIndexPatternContext } from './hooks/use_app_index_pattern';
import { SeriesBuilder } from './series_builder/series_builder';
import { SeriesUrl } from './types';
export const combineTimeRanges = (
allSeries: Record<string, SeriesUrl>,
firstSeries?: SeriesUrl
) => {
let to: string = '';
let from: string = '';
if (firstSeries?.reportType === 'kpi-over-time') {
return firstSeries.time;
}
Object.values(allSeries ?? {}).forEach((series) => {
if (series.dataType && series.reportType && !isEmpty(series.reportDefinitions)) {
const seriesTo = new Date(series.time.to);
const seriesFrom = new Date(series.time.from);
if (!to || seriesTo > new Date(to)) {
to = series.time.to;
}
if (!from || seriesFrom < new Date(from)) {
from = series.time.from;
}
}
});
return { to, from };
};
export function ExploratoryView({
saveAttributes,
multiSeries,
}: {
multiSeries?: boolean;
saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void;
}) {
const {
@ -33,6 +61,8 @@ export function ExploratoryView({
const [height, setHeight] = useState<string>('100vh');
const [seriesId, setSeriesId] = useState<string>('');
const [lastUpdated, setLastUpdated] = useState<number | undefined>();
const [lensAttributes, setLensAttributes] = useState<TypedLensByValueInput['attributes'] | null>(
null
);
@ -47,9 +77,7 @@ export function ExploratoryView({
setSeriesId(firstSeriesId);
}, [allSeries, firstSeriesId]);
const lensAttributesT = useLensAttributes({
seriesId,
});
const lensAttributesT = useLensAttributes();
const setHeightOffset = () => {
if (seriesBuilderRef?.current && wrapperRef.current) {
@ -60,10 +88,12 @@ export function ExploratoryView({
};
useEffect(() => {
if (series?.dataType) {
loadIndexPattern({ dataType: series?.dataType });
}
}, [series?.dataType, loadIndexPattern]);
Object.values(allSeries).forEach((seriesT) => {
loadIndexPattern({
dataType: seriesT.dataType,
});
});
}, [allSeries, loadIndexPattern]);
useEffect(() => {
setLensAttributes(lensAttributesT);
@ -72,47 +102,62 @@ export function ExploratoryView({
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(lensAttributesT ?? {}), series?.reportType, series?.time?.from]);
}, [JSON.stringify(lensAttributesT ?? {})]);
useEffect(() => {
setHeightOffset();
});
const timeRange = combineTimeRanges(allSeries, series);
const onLensLoad = useCallback(() => {
setLastUpdated(Date.now());
}, []);
const onBrushEnd = useCallback(
({ range }: { range: number[] }) => {
if (series?.reportType !== 'data-distribution') {
setSeries(seriesId, {
...series,
time: {
from: new Date(range[0]).toISOString(),
to: new Date(range[1]).toISOString(),
},
});
} else {
notifications?.toasts.add(
i18n.translate('xpack.observability.exploratoryView.noBrusing', {
defaultMessage: 'Zoom by brush selection is only available on time series charts.',
})
);
}
},
[notifications?.toasts, series, seriesId, setSeries]
);
return (
<Wrapper>
{lens ? (
<>
<ExploratoryViewHeader lensAttributes={lensAttributes} seriesId={seriesId} />
<LensWrapper ref={wrapperRef} height={height}>
{lensAttributes && seriesId && series?.reportType && series?.time ? (
{lensAttributes && timeRange.to && timeRange.from ? (
<LensComponent
id="exploratoryView"
timeRange={series?.time}
timeRange={timeRange}
attributes={lensAttributes}
onBrushEnd={({ range }) => {
if (series?.reportType !== 'dist') {
setSeries(seriesId, {
...series,
time: {
from: new Date(range[0]).toISOString(),
to: new Date(range[1]).toISOString(),
},
});
} else {
notifications?.toasts.add(
i18n.translate('xpack.observability.exploratoryView.noBrusing', {
defaultMessage:
'Zoom by brush selection is only available on time series charts.',
})
);
}
}}
onLoad={onLensLoad}
onBrushEnd={onBrushEnd}
/>
) : (
<EmptyView series={series} loading={loading} height={height} />
)}
</LensWrapper>
<SeriesBuilder seriesId={seriesId} seriesBuilderRef={seriesBuilderRef} />
<SeriesBuilder
seriesBuilderRef={seriesBuilderRef}
lastUpdated={lastUpdated}
multiSeries={multiSeries}
/>
</>
) : (
<EuiTitle>

View file

@ -26,7 +26,7 @@ describe('ExploratoryViewHeader', function () {
data: {
'uptime-pings-histogram': {
dataType: 'synthetics' as const,
reportType: 'kpi' as const,
reportType: 'kpi-over-time' as const,
breakdown: 'monitor.status',
time: { from: 'now-15m', to: 'now' },
},

View file

@ -13,6 +13,7 @@ import { useKibana } from '../../../../../../../../src/plugins/kibana_react/publ
import { DataViewLabels } from '../configurations/constants';
import { ObservabilityAppServices } from '../../../../application/types';
import { useSeriesStorage } from '../hooks/use_series_storage';
import { combineTimeRanges } from '../exploratory_view';
interface Props {
seriesId: string;
@ -24,7 +25,7 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) {
const { lens } = kServices;
const { getSeries } = useSeriesStorage();
const { getSeries, allSeries } = useSeriesStorage();
const series = getSeries(seriesId);
@ -32,6 +33,8 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) {
const LensSaveModalComponent = lens.SaveModalComponent;
const timeRange = combineTimeRanges(allSeries, series);
return (
<>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
@ -63,7 +66,7 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) {
lens.navigateToPrefilledEditor(
{
id: '',
timeRange: series.time,
timeRange,
attributes: lensAttributes,
},
true

View file

@ -15,7 +15,6 @@ import { getDataHandler } from '../../../../data_handler';
export interface IIndexPatternContext {
loading: boolean;
selectedApp: AppDataType;
indexPatterns: IndexPatternState;
hasAppData: HasAppDataState;
loadIndexPattern: (params: { dataType: AppDataType }) => void;
@ -29,10 +28,10 @@ interface ProviderProps {
type HasAppDataState = Record<AppDataType, boolean | null>;
type IndexPatternState = Record<AppDataType, IndexPattern>;
type LoadingState = Record<AppDataType, boolean>;
export function IndexPatternContextProvider({ children }: ProviderProps) {
const [loading, setLoading] = useState(false);
const [selectedApp, setSelectedApp] = useState<AppDataType>();
const [loading, setLoading] = useState<LoadingState>({} as LoadingState);
const [indexPatterns, setIndexPatterns] = useState<IndexPatternState>({} as IndexPatternState);
const [hasAppData, setHasAppData] = useState<HasAppDataState>({
infra_metrics: null,
@ -49,10 +48,9 @@ export function IndexPatternContextProvider({ children }: ProviderProps) {
const loadIndexPattern: IIndexPatternContext['loadIndexPattern'] = useCallback(
async ({ dataType }) => {
setSelectedApp(dataType);
if (hasAppData[dataType] === null && !loading[dataType]) {
setLoading((prevState) => ({ ...prevState, [dataType]: true }));
if (hasAppData[dataType] === null) {
setLoading(true);
try {
let hasDataT = false;
let indices: string | undefined = '';
@ -78,23 +76,22 @@ export function IndexPatternContextProvider({ children }: ProviderProps) {
setIndexPatterns((prevState) => ({ ...prevState, [dataType]: indPattern }));
}
setLoading(false);
setLoading((prevState) => ({ ...prevState, [dataType]: false }));
} catch (e) {
setLoading(false);
setLoading((prevState) => ({ ...prevState, [dataType]: false }));
}
}
},
[data, hasAppData]
[data, hasAppData, loading]
);
return (
<IndexPatternContext.Provider
value={{
loading,
hasAppData,
selectedApp,
indexPatterns,
loadIndexPattern,
loading: !!Object.values(loading).find((loadingT) => loadingT),
}}
>
{children}
@ -102,19 +99,23 @@ export function IndexPatternContextProvider({ children }: ProviderProps) {
);
}
export const useAppIndexPatternContext = () => {
const { selectedApp, loading, hasAppData, loadIndexPattern, indexPatterns } = useContext(
export const useAppIndexPatternContext = (dataType?: AppDataType) => {
const { loading, hasAppData, loadIndexPattern, indexPatterns } = useContext(
(IndexPatternContext as unknown) as Context<IIndexPatternContext>
);
if (dataType && !indexPatterns?.[dataType] && !loading) {
loadIndexPattern({ dataType });
}
return useMemo(() => {
return {
hasAppData,
selectedApp,
loading,
indexPattern: indexPatterns?.[selectedApp],
hasData: hasAppData?.[selectedApp],
indexPatterns,
indexPattern: dataType ? indexPatterns?.[dataType] : undefined,
hasData: dataType ? hasAppData?.[dataType] : undefined,
loadIndexPattern,
};
}, [hasAppData, indexPatterns, loadIndexPattern, loading, selectedApp]);
}, [dataType, hasAppData, indexPatterns, loadIndexPattern, loading]);
};

View file

@ -8,17 +8,13 @@
import { useMemo } from 'react';
import { isEmpty } from 'lodash';
import { TypedLensByValueInput } from '../../../../../../lens/public';
import { LensAttributes } from '../configurations/lens_attributes';
import { LayerConfig, LensAttributes } from '../configurations/lens_attributes';
import { useSeriesStorage } from './use_series_storage';
import { getDefaultConfigs } from '../configurations/default_configs';
import { DataSeries, SeriesUrl, UrlFilter } from '../types';
import { useAppIndexPatternContext } from './use_app_index_pattern';
interface Props {
seriesId: string;
}
export const getFiltersFromDefs = (
reportDefinitions: SeriesUrl['reportDefinitions'],
dataViewConfig: DataSeries
@ -37,54 +33,51 @@ export const getFiltersFromDefs = (
});
};
export const useLensAttributes = ({
seriesId,
}: Props): TypedLensByValueInput['attributes'] | null => {
const { getSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const { breakdown, seriesType, operationType, reportType, dataType, reportDefinitions = {} } =
series ?? {};
export const useLensAttributes = (): TypedLensByValueInput['attributes'] | null => {
const { allSeriesIds, allSeries } = useSeriesStorage();
const { indexPattern } = useAppIndexPatternContext();
const { indexPatterns } = useAppIndexPatternContext();
return useMemo(() => {
if (!indexPattern || !reportType || isEmpty(reportDefinitions)) {
if (isEmpty(indexPatterns) || isEmpty(allSeriesIds)) {
return null;
}
const dataViewConfig = getDefaultConfigs({
reportType,
dataType,
indexPattern,
const layerConfigs: LayerConfig[] = [];
allSeriesIds.forEach((seriesIdT) => {
const seriesT = allSeries[seriesIdT];
const indexPattern = indexPatterns?.[seriesT?.dataType];
if (indexPattern && seriesT.reportType && !isEmpty(seriesT.reportDefinitions)) {
const reportViewConfig = getDefaultConfigs({
reportType: seriesT.reportType,
dataType: seriesT.dataType,
indexPattern,
});
const filters: UrlFilter[] = (seriesT.filters ?? []).concat(
getFiltersFromDefs(seriesT.reportDefinitions, reportViewConfig)
);
layerConfigs.push({
filters,
indexPattern,
reportConfig: reportViewConfig,
breakdown: seriesT.breakdown,
operationType: seriesT.operationType,
seriesType: seriesT.seriesType,
reportDefinitions: seriesT.reportDefinitions ?? {},
time: seriesT.time,
});
}
});
const filters: UrlFilter[] = (series.filters ?? []).concat(
getFiltersFromDefs(reportDefinitions, dataViewConfig)
);
const lensAttributes = new LensAttributes(
indexPattern,
dataViewConfig,
seriesType,
filters,
operationType,
reportDefinitions,
breakdown
);
if (breakdown) {
lensAttributes.addBreakdown(breakdown);
if (layerConfigs.length < 1) {
return null;
}
const lensAttributes = new LensAttributes(layerConfigs);
return lensAttributes.getJSON();
}, [
indexPattern,
reportType,
reportDefinitions,
dataType,
series.filters,
seriesType,
operationType,
breakdown,
]);
}, [indexPatterns, allSeriesIds, allSeries]);
};

View file

@ -12,7 +12,7 @@ import {
} from '../../../../../../../../src/plugins/kibana_utils/public';
import type {
AppDataType,
ReportViewTypeId,
ReportViewType,
SeriesUrl,
UrlFilter,
URLReportDefinition,
@ -36,6 +36,16 @@ interface ProviderProps {
storage: IKbnUrlStateStorage | ISessionStorageStateStorage;
}
function convertAllShortSeries(allShortSeries: AllShortSeries) {
const allSeriesIds = Object.keys(allShortSeries);
const allSeriesN: AllSeries = {};
allSeriesIds.forEach((seriesKey) => {
allSeriesN[seriesKey] = convertFromShortUrl(allShortSeries[seriesKey]);
});
return allSeriesN;
}
export function UrlStorageContextProvider({
children,
storage,
@ -45,15 +55,14 @@ export function UrlStorageContextProvider({
const [allShortSeries, setAllShortSeries] = useState<AllShortSeries>(
() => storage.get(allSeriesKey) ?? {}
);
const [allSeries, setAllSeries] = useState<AllSeries>({});
const [allSeries, setAllSeries] = useState<AllSeries>(() =>
convertAllShortSeries(storage.get(allSeriesKey) ?? {})
);
const [firstSeriesId, setFirstSeriesId] = useState('');
useEffect(() => {
const allSeriesIds = Object.keys(allShortSeries);
const allSeriesN: AllSeries = {};
allSeriesIds.forEach((seriesKey) => {
allSeriesN[seriesKey] = convertFromShortUrl(allShortSeries[seriesKey]);
});
const allSeriesN: AllSeries = convertAllShortSeries(allShortSeries ?? {});
setAllSeries(allSeriesN);
setFirstSeriesId(allSeriesIds?.[0]);
@ -68,8 +77,10 @@ export function UrlStorageContextProvider({
};
const removeSeries = (seriesIdN: string) => {
delete allShortSeries[seriesIdN];
delete allSeries[seriesIdN];
setAllShortSeries((prevState) => {
delete prevState[seriesIdN];
return { ...prevState };
});
};
const allSeriesIds = Object.keys(allShortSeries);
@ -115,7 +126,7 @@ function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl {
interface ShortUrlSeries {
[URL_KEYS.OPERATION_TYPE]?: OperationType;
[URL_KEYS.REPORT_TYPE]?: ReportViewTypeId;
[URL_KEYS.REPORT_TYPE]?: ReportViewType;
[URL_KEYS.DATA_TYPE]?: AppDataType;
[URL_KEYS.SERIES_TYPE]?: SeriesType;
[URL_KEYS.BREAK_DOWN]?: string;

View file

@ -25,9 +25,11 @@ import { TypedLensByValueInput } from '../../../../../lens/public';
export function ExploratoryViewPage({
saveAttributes,
multiSeries = false,
useSessionStorage = false,
}: {
useSessionStorage?: boolean;
multiSeries?: boolean;
saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void;
}) {
useTrackPageview({ app: 'observability-overview', path: 'exploratory-view' });
@ -59,7 +61,7 @@ export function ExploratoryViewPage({
<Wrapper>
<IndexPatternContextProvider>
<UrlStorageContextProvider storage={kbnUrlStateStorage}>
<ExploratoryView saveAttributes={saveAttributes} />
<ExploratoryView saveAttributes={saveAttributes} multiSeries={multiSeries} />
</UrlStorageContextProvider>
</IndexPatternContextProvider>
</Wrapper>

View file

@ -35,8 +35,11 @@ import { getStubIndexPattern } from '../../../../../../../src/plugins/data/publi
import indexPatternData from './configurations/test_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 {
IndexPattern,
IndexPatternsContract,
} from '../../../../../../../src/plugins/data/common/index_patterns/index_patterns';
import { AppDataType, UrlFilter } from './types';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
import { ListItem } from '../../../hooks/use_values_list';
@ -232,11 +235,11 @@ export const mockAppIndexPattern = () => {
const loadIndexPattern = jest.fn();
const spy = jest.spyOn(useAppIndexPatternHook, 'useAppIndexPatternContext').mockReturnValue({
indexPattern: mockIndexPattern,
selectedApp: 'ux',
hasData: true,
loading: false,
hasAppData: { ux: true } as any,
loadIndexPattern,
indexPatterns: ({ ux: mockIndexPattern } as unknown) as Record<AppDataType, IndexPattern>,
});
return { spy, loadIndexPattern };
};
@ -260,7 +263,7 @@ function mockSeriesStorageContext({
}) {
const mockDataSeries = data || {
'performance-distribution': {
reportType: 'dist',
reportType: 'data-distribution',
dataType: 'ux',
breakdown: breakdown || 'user_agent.name',
time: { from: 'now-15m', to: 'now' },

View file

@ -27,18 +27,14 @@ export function SeriesChartTypesSelect({
seriesTypes?: SeriesType[];
defaultChartType: SeriesType;
}) {
const { getSeries, setSeries, allSeries } = useSeriesStorage();
const { getSeries, setSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const seriesType = series?.seriesType ?? defaultChartType;
const onChange = (value: SeriesType) => {
Object.keys(allSeries).forEach((seriesKey) => {
const seriesN = allSeries[seriesKey];
setSeries(seriesKey, { ...seriesN, seriesType: value });
});
setSeries(seriesId, { ...series, seriesType: value });
};
return (

View file

@ -29,7 +29,14 @@ describe('DataTypesCol', function () {
fireEvent.click(screen.getByText(/user experience \(rum\)/i));
expect(setSeries).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith(seriesId, { dataType: 'ux' });
expect(setSeries).toHaveBeenCalledWith(seriesId, {
dataType: 'ux',
isNew: true,
time: {
from: 'now-15m',
to: 'now',
},
});
});
it('should set series on change on already selected', function () {
@ -37,7 +44,7 @@ describe('DataTypesCol', function () {
data: {
[seriesId]: {
dataType: 'synthetics' as const,
reportType: 'kpi' as const,
reportType: 'kpi-over-time' as const,
breakdown: 'monitor.status',
time: { from: 'now-15m', to: 'now' },
},

View file

@ -31,7 +31,11 @@ export function DataTypesCol({ seriesId }: { seriesId: string }) {
if (!dataType) {
removeSeries(seriesId);
} else {
setSeries(seriesId || `${dataType}-series`, { dataType } as any);
setSeries(seriesId || `${dataType}-series`, {
dataType,
isNew: true,
time: series.time,
} as any);
}
};

View file

@ -8,14 +8,23 @@
import React from 'react';
import styled from 'styled-components';
import { SeriesDatePicker } from '../../series_date_picker';
import { DateRangePicker } from '../../series_date_picker/date_range_picker';
import { useSeriesStorage } from '../../hooks/use_series_storage';
interface Props {
seriesId: string;
}
export function DatePickerCol({ seriesId }: Props) {
const { firstSeriesId, getSeries } = useSeriesStorage();
const { reportType } = getSeries(firstSeriesId);
return (
<Wrapper>
<SeriesDatePicker seriesId={seriesId} />
{firstSeriesId === seriesId || reportType !== 'kpi-over-time' ? (
<SeriesDatePicker seriesId={seriesId} />
) : (
<DateRangePicker seriesId={seriesId} />
)}
</Wrapper>
);
}

View file

@ -22,7 +22,7 @@ describe('OperationTypeSelect', function () {
data: {
'performance-distribution': {
dataType: 'ux' as const,
reportType: 'kpi' as const,
reportType: 'kpi-over-time' as const,
operationType: 'median' as const,
time: { from: 'now-15m', to: 'now' },
},
@ -39,7 +39,7 @@ describe('OperationTypeSelect', function () {
data: {
'series-id': {
dataType: 'ux' as const,
reportType: 'kpi' as const,
reportType: 'kpi-over-time' as const,
operationType: 'median' as const,
time: { from: 'now-15m', to: 'now' },
},
@ -53,7 +53,7 @@ describe('OperationTypeSelect', function () {
expect(setSeries).toHaveBeenCalledWith('series-id', {
operationType: 'median',
dataType: 'ux',
reportType: 'kpi',
reportType: 'kpi-over-time',
time: { from: 'now-15m', to: 'now' },
});
@ -61,7 +61,7 @@ describe('OperationTypeSelect', function () {
expect(setSeries).toHaveBeenCalledWith('series-id', {
operationType: '95th',
dataType: 'ux',
reportType: 'kpi',
reportType: 'kpi-over-time',
time: { from: 'now-15m', to: 'now' },
});
});

View file

@ -15,7 +15,7 @@ import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fiel
describe('Series Builder ReportBreakdowns', function () {
const seriesId = 'test-series-id';
const dataViewSeries = getDefaultConfigs({
reportType: 'dist',
reportType: 'data-distribution',
dataType: 'ux',
indexPattern: mockIndexPattern,
});
@ -45,7 +45,7 @@ describe('Series Builder ReportBreakdowns', function () {
expect(setSeries).toHaveBeenCalledWith(seriesId, {
breakdown: USER_AGENT_OS,
dataType: 'ux',
reportType: 'dist',
reportType: 'data-distribution',
time: { from: 'now-15m', to: 'now' },
});
});
@ -67,7 +67,7 @@ describe('Series Builder ReportBreakdowns', function () {
expect(setSeries).toHaveBeenCalledWith(seriesId, {
breakdown: undefined,
dataType: 'ux',
reportType: 'dist',
reportType: 'data-distribution',
time: { from: 'now-15m', to: 'now' },
});
});

View file

@ -22,7 +22,7 @@ describe('Series Builder ReportDefinitionCol', function () {
const seriesId = 'test-series-id';
const dataViewSeries = getDefaultConfigs({
reportType: 'dist',
reportType: 'data-distribution',
indexPattern: mockIndexPattern,
dataType: 'ux',
});
@ -31,7 +31,7 @@ describe('Series Builder ReportDefinitionCol', function () {
data: {
[seriesId]: {
dataType: 'ux' as const,
reportType: 'dist' as const,
reportType: 'data-distribution' as const,
time: { from: 'now-30d', to: 'now' },
reportDefinitions: { [SERVICE_NAME]: ['elastic-co'] },
},
@ -81,7 +81,7 @@ describe('Series Builder ReportDefinitionCol', function () {
expect(setSeries).toHaveBeenCalledWith(seriesId, {
dataType: 'ux',
reportDefinitions: {},
reportType: 'dist',
reportType: 'data-distribution',
time: { from: 'now-30d', to: 'now' },
});
});

View file

@ -8,7 +8,6 @@
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui';
import styled from 'styled-components';
import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { CustomReportField } from '../custom_report_field';
import { DataSeries, URLReportDefinition } from '../../types';
@ -36,8 +35,6 @@ export function ReportDefinitionCol({
dataViewSeries: DataSeries;
seriesId: string;
}) {
const { indexPattern } = useAppIndexPatternContext();
const { getSeries, setSeries } = useSeriesStorage();
const series = getSeries(seriesId);
@ -69,21 +66,20 @@ export function ReportDefinitionCol({
<DatePickerCol seriesId={seriesId} />
</EuiFlexItem>
<EuiHorizontalRule margin="xs" />
{indexPattern &&
reportDefinitions.map(({ field, custom, options }) => (
<EuiFlexItem key={field}>
{!custom ? (
<ReportDefinitionField
seriesId={seriesId}
dataSeries={dataViewSeries}
field={field}
onChange={onChange}
/>
) : (
<CustomReportField field={field} options={options} seriesId={seriesId} />
)}
</EuiFlexItem>
))}
{reportDefinitions.map(({ field, custom, options }) => (
<EuiFlexItem key={field}>
{!custom ? (
<ReportDefinitionField
seriesId={seriesId}
dataSeries={dataViewSeries}
field={field}
onChange={onChange}
/>
) : (
<CustomReportField field={field} options={options} seriesId={seriesId} />
)}
</EuiFlexItem>
))}
{(hasOperationType || columnType === 'operation') && (
<EuiFlexItem>
<OperationTypeSelect

View file

@ -29,7 +29,7 @@ export function ReportDefinitionField({ seriesId, field, dataSeries, onChange }:
const series = getSeries(seriesId);
const { indexPattern } = useAppIndexPatternContext();
const { indexPattern } = useAppIndexPatternContext(series.dataType);
const { reportDefinitions: selectedReportDefinitions = {} } = series;
@ -49,7 +49,7 @@ export function ReportDefinitionField({ seriesId, field, dataSeries, onChange }:
if (!isEmpty(selectedReportDefinitions)) {
reportDefinitions.forEach(({ field: fieldT, custom }) => {
if (!custom && selectedReportDefinitions?.[fieldT] && fieldT !== field) {
if (!custom && indexPattern && selectedReportDefinitions?.[fieldT] && fieldT !== field) {
const values = selectedReportDefinitions?.[fieldT];
const valueFilter = buildPhrasesFilter(fieldT, values, indexPattern)[0];
filtersN.push(valueFilter.query);
@ -64,16 +64,18 @@ export function ReportDefinitionField({ seriesId, field, dataSeries, onChange }:
return (
<EuiFlexGroup justifyContent="flexStart" gutterSize="s" alignItems="center" wrap>
<EuiFlexItem>
<FieldValueSuggestions
label={labels[field]}
sourceField={field}
indexPatternTitle={indexPattern.title}
selectedValue={selectedReportDefinitions?.[field]}
onChange={(val?: string[]) => onChange(field, val)}
filters={queryFilters}
time={series.time}
fullWidth={true}
/>
{indexPattern && (
<FieldValueSuggestions
label={labels[field]}
sourceField={field}
indexPatternTitle={indexPattern.title}
selectedValue={selectedReportDefinitions?.[field]}
onChange={(val?: string[]) => onChange(field, val)}
filters={queryFilters}
time={series.time}
fullWidth={true}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -15,7 +15,7 @@ describe('Series Builder ReportFilters', function () {
const seriesId = 'test-series-id';
const dataViewSeries = getDefaultConfigs({
reportType: 'dist',
reportType: 'data-distribution',
indexPattern: mockIndexPattern,
dataType: 'ux',
});

View file

@ -11,10 +11,9 @@ import { mockAppIndexPattern, render } from '../../rtl_helpers';
import { ReportTypesCol, SELECTED_DATA_TYPE_FOR_REPORT } from './report_types_col';
import { ReportTypes } from '../series_builder';
import { DEFAULT_TIME } from '../../configurations/constants';
import { NEW_SERIES_KEY } from '../../hooks/use_series_storage';
describe('ReportTypesCol', function () {
const seriesId = 'test-series-id';
const seriesId = 'performance-distribution';
mockAppIndexPattern();
@ -40,7 +39,7 @@ describe('ReportTypesCol', function () {
breakdown: 'user_agent.name',
dataType: 'ux',
reportDefinitions: {},
reportType: 'kpi',
reportType: 'kpi-over-time',
time: { from: 'now-15m', to: 'now' },
});
expect(setSeries).toHaveBeenCalledTimes(1);
@ -49,11 +48,12 @@ describe('ReportTypesCol', function () {
it('should set selected as filled', function () {
const initSeries = {
data: {
[NEW_SERIES_KEY]: {
[seriesId]: {
dataType: 'synthetics' as const,
reportType: 'kpi' as const,
reportType: 'kpi-over-time' as const,
breakdown: 'monitor.status',
time: { from: 'now-15m', to: 'now' },
isNew: true,
},
},
};
@ -74,6 +74,7 @@ describe('ReportTypesCol', function () {
expect(setSeries).toHaveBeenCalledWith(seriesId, {
dataType: 'synthetics',
time: DEFAULT_TIME,
isNew: true,
});
});
});

View file

@ -7,27 +7,33 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { map } from 'lodash';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import styled from 'styled-components';
import { ReportViewTypeId, SeriesUrl } from '../../types';
import { ReportViewType, SeriesUrl } from '../../types';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { DEFAULT_TIME } from '../../configurations/constants';
import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern';
import { ReportTypeItem, SELECT_DATA_TYPE } from '../series_builder';
interface Props {
seriesId: string;
reportTypes: Array<{ id: ReportViewTypeId; label: string }>;
reportTypes: ReportTypeItem[];
}
export function ReportTypesCol({ seriesId, reportTypes }: Props) {
const { setSeries, getSeries } = useSeriesStorage();
const { setSeries, getSeries, firstSeries, firstSeriesId } = useSeriesStorage();
const { reportType: selectedReportType, ...restSeries } = getSeries(seriesId);
const { loading, hasData, selectedApp } = useAppIndexPatternContext();
const { loading, hasData } = useAppIndexPatternContext(restSeries.dataType);
if (!loading && !hasData && selectedApp) {
if (!restSeries.dataType) {
return <span>{SELECT_DATA_TYPE}</span>;
}
if (!loading && !hasData) {
return (
<FormattedMessage
id="xpack.observability.reportTypeCol.nodata"
@ -36,9 +42,16 @@ export function ReportTypesCol({ seriesId, reportTypes }: Props) {
);
}
const disabledReportTypes: ReportViewType[] = map(
reportTypes.filter(
({ reportType }) => firstSeriesId !== seriesId && reportType !== firstSeries.reportType
),
'reportType'
);
return reportTypes?.length > 0 ? (
<FlexGroup direction="column" gutterSize="xs">
{reportTypes.map(({ id: reportType, label }) => (
{reportTypes.map(({ reportType, label }) => (
<EuiFlexItem key={reportType}>
<Button
fullWidth
@ -47,12 +60,13 @@ export function ReportTypesCol({ seriesId, reportTypes }: Props) {
iconType="arrowRight"
color={selectedReportType === reportType ? 'primary' : 'text'}
fill={selectedReportType === reportType}
isDisabled={loading}
isDisabled={loading || disabledReportTypes.includes(reportType)}
onClick={() => {
if (reportType === selectedReportType) {
setSeries(seriesId, {
dataType: restSeries.dataType,
time: DEFAULT_TIME,
isNew: true,
} as SeriesUrl);
} else {
setSeries(seriesId, {

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import { EuiIcon, EuiText } from '@elastic/eui';
import moment from 'moment';
interface Props {
lastUpdated?: number;
}
export function LastUpdated({ lastUpdated }: Props) {
const [refresh, setRefresh] = useState(() => Date.now());
useEffect(() => {
const interVal = setInterval(() => {
setRefresh(Date.now());
}, 1000);
return () => {
clearInterval(interVal);
};
}, []);
if (!lastUpdated) {
return null;
}
return (
<EuiText color="subdued" size="s">
<EuiIcon type="clock" /> Last Updated: {moment(lastUpdated).from(refresh)}
</EuiText>
);
}

View file

@ -5,11 +5,19 @@
* 2.0.
*/
import React, { RefObject } from 'react';
import React, { RefObject, useEffect, useState } from 'react';
import { isEmpty } from 'lodash';
import { i18n } from '@kbn/i18n';
import { EuiBasicTable } from '@elastic/eui';
import { AppDataType, ReportViewTypeId, ReportViewTypes, SeriesUrl } from '../types';
import {
EuiBasicTable,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiSwitch,
} from '@elastic/eui';
import { rgba } from 'polished';
import { AppDataType, DataSeries, ReportViewType, SeriesUrl } from '../types';
import { DataTypesCol } from './columns/data_types_col';
import { ReportTypesCol } from './columns/report_types_col';
import { ReportDefinitionCol } from './columns/report_definition_col';
@ -18,6 +26,10 @@ import { ReportBreakdowns } from './columns/report_breakdowns';
import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage';
import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern';
import { getDefaultConfigs } from '../configurations/default_configs';
import { SeriesEditor } from '../series_editor/series_editor';
import { SeriesActions } from '../series_editor/columns/series_actions';
import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common';
import { LastUpdated } from './last_updated';
import {
CORE_WEB_VITALS_LABEL,
DEVICE_DISTRIBUTION_LABEL,
@ -25,72 +37,94 @@ import {
PERF_DIST_LABEL,
} from '../configurations/constants/labels';
export const ReportTypes: Record<AppDataType, Array<{ id: ReportViewTypeId; label: string }>> = {
export interface ReportTypeItem {
id: string;
reportType: ReportViewType;
label: string;
}
export const ReportTypes: Record<AppDataType, ReportTypeItem[]> = {
synthetics: [
{ id: 'kpi', label: KPI_OVER_TIME_LABEL },
{ id: 'dist', label: PERF_DIST_LABEL },
{ id: 'kpi', reportType: 'kpi-over-time', label: KPI_OVER_TIME_LABEL },
{ id: 'dist', reportType: 'data-distribution', label: PERF_DIST_LABEL },
],
ux: [
{ id: 'kpi', label: KPI_OVER_TIME_LABEL },
{ id: 'dist', label: PERF_DIST_LABEL },
{ id: 'cwv', label: CORE_WEB_VITALS_LABEL },
{ id: 'kpi', reportType: 'kpi-over-time', label: KPI_OVER_TIME_LABEL },
{ id: 'dist', reportType: 'data-distribution', label: PERF_DIST_LABEL },
{ id: 'cwv', reportType: 'core-web-vitals', label: CORE_WEB_VITALS_LABEL },
],
mobile: [
{ id: 'kpi', label: KPI_OVER_TIME_LABEL },
{ id: 'dist', label: PERF_DIST_LABEL },
{ id: 'mdd', label: DEVICE_DISTRIBUTION_LABEL },
{ id: 'kpi', reportType: 'kpi-over-time', label: KPI_OVER_TIME_LABEL },
{ id: 'dist', reportType: 'data-distribution', label: PERF_DIST_LABEL },
{ id: 'mdd', reportType: 'device-data-distribution', label: DEVICE_DISTRIBUTION_LABEL },
],
apm: [],
infra_logs: [],
infra_metrics: [],
};
interface BuilderItem {
id: string;
series: SeriesUrl;
seriesConfig?: DataSeries;
}
export function SeriesBuilder({
seriesBuilderRef,
seriesId,
lastUpdated,
multiSeries,
}: {
seriesId: string;
seriesBuilderRef: RefObject<HTMLDivElement>;
lastUpdated?: number;
multiSeries?: boolean;
}) {
const { getSeries, setSeries, removeSeries } = useSeriesStorage();
const [editorItems, setEditorItems] = useState<BuilderItem[]>([]);
const { getSeries, allSeries, allSeriesIds, setSeries, removeSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const { loading, indexPatterns } = useAppIndexPatternContext();
const {
dataType,
seriesType,
reportType,
reportDefinitions = {},
filters = [],
operationType,
breakdown,
time,
} = series;
useEffect(() => {
const getDataViewSeries = (dataType: AppDataType, reportType: SeriesUrl['reportType']) => {
if (indexPatterns?.[dataType]) {
return getDefaultConfigs({
dataType,
indexPattern: indexPatterns[dataType],
reportType: reportType!,
});
}
};
const { indexPattern, loading, hasData } = useAppIndexPatternContext();
const seriesToEdit: BuilderItem[] =
allSeriesIds
.filter((sId) => {
return allSeries?.[sId]?.isNew;
})
.map((sId) => {
const series = getSeries(sId);
const seriesConfig = getDataViewSeries(series.dataType, series.reportType);
const getDataViewSeries = () => {
return getDefaultConfigs({
dataType,
indexPattern,
reportType: reportType!,
});
};
return { id: sId, series, seriesConfig };
}) ?? [];
const initSeries: BuilderItem[] = [{ id: 'series-id', series: {} as SeriesUrl }];
setEditorItems(multiSeries || seriesToEdit.length > 0 ? seriesToEdit : initSeries);
}, [allSeries, allSeriesIds, getSeries, indexPatterns, loading, multiSeries]);
const columns = [
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.dataType', {
defaultMessage: 'Data Type',
}),
field: 'id',
width: '15%',
render: (val: string) => <DataTypesCol seriesId={seriesId} />,
render: (seriesId: string) => <DataTypesCol seriesId={seriesId} />,
},
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.report', {
defaultMessage: 'Report',
}),
width: '15%',
render: (val: string) => (
field: 'id',
render: (seriesId: string, { series: { dataType } }: BuilderItem) => (
<ReportTypesCol seriesId={seriesId} reportTypes={dataType ? ReportTypes[dataType] : []} />
),
},
@ -99,12 +133,16 @@ export function SeriesBuilder({
defaultMessage: 'Definition',
}),
width: '30%',
render: (val: string) => {
if (dataType && hasData) {
field: 'id',
render: (
seriesId: string,
{ series: { dataType, reportType }, seriesConfig }: BuilderItem
) => {
if (dataType && seriesConfig) {
return loading ? (
LOADING_VIEW
) : reportType ? (
<ReportDefinitionCol seriesId={seriesId} dataViewSeries={getDataViewSeries()} />
<ReportDefinitionCol seriesId={seriesId} dataViewSeries={seriesConfig} />
) : (
SELECT_REPORT_TYPE
);
@ -118,9 +156,10 @@ export function SeriesBuilder({
defaultMessage: 'Filters',
}),
width: '20%',
render: (val: string) =>
reportType && indexPattern ? (
<ReportFilters seriesId={seriesId} dataViewSeries={getDataViewSeries()} />
field: 'id',
render: (seriesId: string, { series: { reportType }, seriesConfig }: BuilderItem) =>
reportType && seriesConfig ? (
<ReportFilters seriesId={seriesId} dataViewSeries={seriesConfig} />
) : null,
},
{
@ -129,53 +168,126 @@ export function SeriesBuilder({
}),
width: '20%',
field: 'id',
render: (val: string) =>
reportType && indexPattern ? (
<ReportBreakdowns seriesId={seriesId} dataViewSeries={getDataViewSeries()} />
render: (seriesId: string, { series: { reportType }, seriesConfig }: BuilderItem) =>
reportType && seriesConfig ? (
<ReportBreakdowns seriesId={seriesId} dataViewSeries={seriesConfig} />
) : null,
},
...(multiSeries
? [
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.actions', {
defaultMessage: 'Actions',
}),
align: 'center' as const,
width: '10%',
field: 'id',
render: (seriesId: string, item: BuilderItem) => (
<SeriesActions seriesId={seriesId} editorMode={true} />
),
},
]
: []),
];
// TODO: Remove this if remain unused during multiple series view
// @ts-expect-error
const addSeries = () => {
if (reportType) {
const newSeriesId = `${
reportDefinitions?.['service.name'] ||
reportDefinitions?.['monitor.id'] ||
ReportViewTypes[reportType]
}`;
const applySeries = () => {
editorItems.forEach(({ series, id: seriesId }) => {
const { reportType, reportDefinitions, isNew, ...restSeries } = series;
const newSeriesN: SeriesUrl = {
dataType,
time,
filters,
breakdown,
reportType,
seriesType,
operationType,
reportDefinitions,
};
if (reportType && !isEmpty(reportDefinitions)) {
const reportDefId = Object.values(reportDefinitions ?? {})[0];
const newSeriesId = `${reportDefId}-${reportType}`;
setSeries(newSeriesId, newSeriesN);
removeSeries(NEW_SERIES_KEY);
}
const newSeriesN: SeriesUrl = {
...restSeries,
reportType,
reportDefinitions,
};
setSeries(newSeriesId, newSeriesN);
removeSeries(seriesId);
}
});
};
const items = [{ id: seriesId }];
const addSeries = () => {
const prevSeries = allSeries?.[allSeriesIds?.[0]];
setSeries(
`${NEW_SERIES_KEY}-${editorItems.length + 1}`,
prevSeries
? ({ isNew: true, time: prevSeries.time } as SeriesUrl)
: ({ isNew: true } as SeriesUrl)
);
};
return (
<div ref={seriesBuilderRef}>
<EuiBasicTable
items={items as any}
columns={columns}
cellProps={{ style: { borderRight: '1px solid #d3dae6', verticalAlign: 'initial' } }}
tableLayout="auto"
/>
</div>
<Wrapper ref={seriesBuilderRef}>
{multiSeries && (
<EuiFlexGroup justifyContent="flexEnd" alignItems="center">
<EuiFlexItem>
<LastUpdated lastUpdated={lastUpdated} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSwitch
label={i18n.translate('xpack.observability.expView.seriesBuilder.autoApply', {
defaultMessage: 'Auto apply',
})}
checked={true}
onChange={(e) => {}}
compressed
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={() => applySeries()} isDisabled={true} size="s">
{i18n.translate('xpack.observability.expView.seriesBuilder.apply', {
defaultMessage: 'Apply changes',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton color="secondary" onClick={() => addSeries()} size="s">
{i18n.translate('xpack.observability.expView.seriesBuilder.addSeries', {
defaultMessage: 'Add Series',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
)}
<div>
{multiSeries && <SeriesEditor />}
{editorItems.length > 0 && (
<EuiBasicTable
items={editorItems}
columns={columns}
cellProps={{ style: { borderRight: '1px solid #d3dae6', verticalAlign: 'initial' } }}
tableLayout="auto"
/>
)}
<EuiSpacer />
</div>
</Wrapper>
);
}
const Wrapper = euiStyled.div`
max-height: 50vh;
overflow-y: scroll;
overflow-x: clip;
&::-webkit-scrollbar {
height: ${({ theme }) => theme.eui.euiScrollBar};
width: ${({ theme }) => theme.eui.euiScrollBar};
}
&::-webkit-scrollbar-thumb {
background-clip: content-box;
background-color: ${({ theme }) => rgba(theme.eui.euiColorDarkShade, 0.5)};
border: ${({ theme }) => theme.eui.euiScrollBarCorner} solid transparent;
}
&::-webkit-scrollbar-corner,
&::-webkit-scrollbar-track {
background-color: transparent;
}
`;
export const LOADING_VIEW = i18n.translate(
'xpack.observability.expView.seriesBuilder.loadingView',
{
@ -189,3 +301,10 @@ export const SELECT_REPORT_TYPE = i18n.translate(
defaultMessage: 'No report type selected',
}
);
export const SELECT_DATA_TYPE = i18n.translate(
'xpack.observability.expView.seriesBuilder.selectDataType',
{
defaultMessage: 'No data type selected',
}
);

View file

@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiDatePicker, EuiDatePickerRange } from '@elastic/eui';
import DateMath from '@elastic/datemath';
import { Moment } from 'moment';
import { useSeriesStorage } from '../hooks/use_series_storage';
import { useUiSetting } from '../../../../../../../../src/plugins/kibana_react/public';
export const parseAbsoluteDate = (date: string, options = {}) => {
return DateMath.parse(date, options)!;
};
export function DateRangePicker({ seriesId }: { seriesId: string }) {
const { firstSeriesId, getSeries, setSeries } = useSeriesStorage();
const dateFormat = useUiSetting<string>('dateFormat');
const {
time: { from, to },
reportType,
} = getSeries(firstSeriesId);
const series = getSeries(seriesId);
const {
time: { from: seriesFrom, to: seriesTo },
} = series;
const startDate = parseAbsoluteDate(seriesFrom ?? from)!;
const endDate = parseAbsoluteDate(seriesTo ?? to, { roundUp: true })!;
const onStartChange = (newDate: Moment) => {
if (reportType === 'kpi-over-time') {
const mainStartDate = parseAbsoluteDate(from)!;
const mainEndDate = parseAbsoluteDate(to, { roundUp: true })!;
const totalDuration = mainEndDate.diff(mainStartDate, 'millisecond');
const newFrom = newDate.toISOString();
const newTo = newDate.add(totalDuration, 'millisecond').toISOString();
setSeries(seriesId, {
...series,
time: { from: newFrom, to: newTo },
});
} else {
const newFrom = newDate.toISOString();
setSeries(seriesId, {
...series,
time: { from: newFrom, to: seriesTo },
});
}
};
const onEndChange = (newDate: Moment) => {
if (reportType === 'kpi-over-time') {
const mainStartDate = parseAbsoluteDate(from)!;
const mainEndDate = parseAbsoluteDate(to, { roundUp: true })!;
const totalDuration = mainEndDate.diff(mainStartDate, 'millisecond');
const newTo = newDate.toISOString();
const newFrom = newDate.subtract(totalDuration, 'millisecond').toISOString();
setSeries(seriesId, {
...series,
time: { from: newFrom, to: newTo },
});
} else {
const newTo = newDate.toISOString();
setSeries(seriesId, {
...series,
time: { from: seriesFrom, to: newTo },
});
}
};
return (
<EuiDatePickerRange
fullWidth
startDateControl={
<EuiDatePicker
selected={startDate}
onChange={onStartChange}
startDate={startDate}
endDate={endDate}
isInvalid={startDate > endDate}
aria-label={i18n.translate('xpack.observability.expView.dateRanger.startDate', {
defaultMessage: 'Start date',
})}
dateFormat={dateFormat}
showTimeSelect
/>
}
endDateControl={
<EuiDatePicker
selected={endDate}
onChange={onEndChange}
startDate={startDate}
endDate={endDate}
isInvalid={startDate > endDate}
aria-label={i18n.translate('xpack.observability.expView.dateRanger.endDate', {
defaultMessage: 'End date',
})}
dateFormat={dateFormat}
showTimeSelect
/>
}
/>
);
}

View file

@ -43,7 +43,7 @@ export function SeriesDatePicker({ seriesId }: Props) {
if (!series || !series.time) {
setSeries(seriesId, { ...series, time: DEFAULT_TIME });
}
}, [seriesId, series, setSeries]);
}, [series, seriesId, setSeries]);
return (
<EuiSuperDatePicker

View file

@ -17,7 +17,7 @@ describe('SeriesDatePicker', function () {
data: {
'uptime-pings-histogram': {
dataType: 'synthetics' as const,
reportType: 'dist' as const,
reportType: 'data-distribution' as const,
breakdown: 'monitor.status',
time: { from: 'now-30m', to: 'now' },
},
@ -32,7 +32,7 @@ describe('SeriesDatePicker', function () {
const initSeries = {
data: {
'uptime-pings-histogram': {
reportType: 'kpi' as const,
reportType: 'kpi-over-time' as const,
dataType: 'synthetics' as const,
breakdown: 'monitor.status',
},
@ -46,7 +46,7 @@ describe('SeriesDatePicker', function () {
expect(setSeries1).toHaveBeenCalledWith('uptime-pings-histogram', {
breakdown: 'monitor.status',
dataType: 'synthetics' as const,
reportType: 'kpi' as const,
reportType: 'kpi-over-time' as const,
time: DEFAULT_TIME,
});
});
@ -56,7 +56,7 @@ describe('SeriesDatePicker', function () {
data: {
'uptime-pings-histogram': {
dataType: 'synthetics' as const,
reportType: 'kpi' as const,
reportType: 'kpi-over-time' as const,
breakdown: 'monitor.status',
time: { from: 'now-30m', to: 'now' },
},
@ -79,7 +79,7 @@ describe('SeriesDatePicker', function () {
expect(setSeries).toHaveBeenCalledWith('series-id', {
breakdown: 'monitor.status',
dataType: 'synthetics',
reportType: 'kpi',
reportType: 'kpi-over-time',
time: { from: 'now/d', to: 'now/d' },
});
expect(setSeries).toHaveBeenCalledTimes(1);

View file

@ -14,7 +14,7 @@ import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fiel
describe('Breakdowns', function () {
const dataViewSeries = getDefaultConfigs({
reportType: 'dist',
reportType: 'data-distribution',
indexPattern: mockIndexPattern,
dataType: 'ux',
});
@ -52,7 +52,7 @@ describe('Breakdowns', function () {
expect(setSeries).toHaveBeenCalledWith('series-id', {
breakdown: 'user_agent.name',
dataType: 'ux',
reportType: 'dist',
reportType: 'data-distribution',
time: { from: 'now-15m', to: 'now' },
});
expect(setSeries).toHaveBeenCalledTimes(1);

View file

@ -7,14 +7,23 @@
import React from 'react';
import { SeriesDatePicker } from '../../series_date_picker';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { DateRangePicker } from '../../series_date_picker/date_range_picker';
interface Props {
seriesId: string;
}
export function DatePickerCol({ seriesId }: Props) {
const { firstSeriesId, getSeries } = useSeriesStorage();
const { reportType } = getSeries(firstSeriesId);
return (
<div style={{ maxWidth: 300 }}>
<SeriesDatePicker seriesId={seriesId} />
{firstSeriesId === seriesId || reportType !== 'kpi-over-time' ? (
<SeriesDatePicker seriesId={seriesId} />
) : (
<DateRangePicker seriesId={seriesId} />
)}
</div>
);
}

View file

@ -41,8 +41,6 @@ export function FilterExpanded({
isNegated,
filters: defaultFilters,
}: Props) {
const { indexPattern } = useAppIndexPatternContext();
const [value, setValue] = useState('');
const [isOpen, setIsOpen] = useState({ value: '', negate: false });
@ -53,23 +51,25 @@ export function FilterExpanded({
const queryFilters: ESFilter[] = [];
const { indexPatterns } = useAppIndexPatternContext(series.dataType);
defaultFilters?.forEach((qFilter: PersistableFilter | ExistsFilter) => {
if (qFilter.query) {
queryFilters.push(qFilter.query);
}
const asExistFilter = qFilter as ExistsFilter;
if (asExistFilter?.exists) {
queryFilters.push(asExistFilter.exists as QueryDslQueryContainer);
queryFilters.push({ exists: asExistFilter.exists } as QueryDslQueryContainer);
}
});
const { values, loading } = useValuesList({
query: value,
indexPatternTitle: indexPattern?.title,
sourceField: field,
time: series.time,
keepHistory: true,
filters: queryFilters,
indexPatternTitle: indexPatterns[series.dataType]?.title,
});
const filters = series?.filters ?? [];

View file

@ -139,7 +139,7 @@ describe('FilterValueButton', function () {
/>
);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toBeCalledWith(
expect.objectContaining({
filters: [
@ -170,7 +170,7 @@ describe('FilterValueButton', function () {
/>
);
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledTimes(6);
expect(spy).toBeCalledWith(
expect.objectContaining({
filters: [

View file

@ -41,7 +41,7 @@ export function FilterValueButton({
const series = getSeries(seriesId);
const { indexPattern } = useAppIndexPatternContext();
const { indexPatterns } = useAppIndexPatternContext(series.dataType);
const { setFilter, removeFilter } = useSeriesFilters({ seriesId });
@ -96,7 +96,6 @@ export function FilterValueButton({
<FieldValueSuggestions
button={button}
label={'Version'}
indexPatternTitle={indexPattern?.title}
sourceField={nestedField}
onChange={onNestedChange}
filters={filters}
@ -104,6 +103,7 @@ export function FilterValueButton({
anchorPosition="rightCenter"
time={series.time}
asCombobox={false}
indexPatternTitle={indexPatterns[series.dataType]?.title}
/>
) : (
button

View file

@ -26,9 +26,9 @@ export function RemoveSeries({ seriesId }: Props) {
defaultMessage: 'Click to remove series',
})}
iconType="cross"
color="primary"
color="danger"
onClick={onClick}
size="m"
size="s"
/>
);
}

View file

@ -8,33 +8,93 @@
import React from 'react';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash';
import { RemoveSeries } from './remove_series';
import { NEW_SERIES_KEY, useSeriesStorage } from '../../hooks/use_series_storage';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { SeriesUrl } from '../../types';
interface Props {
seriesId: string;
editorMode?: boolean;
}
export function SeriesActions({ seriesId }: Props) {
const { getSeries, removeSeries, setSeries } = useSeriesStorage();
export function SeriesActions({ seriesId, editorMode = false }: Props) {
const { getSeries, setSeries, allSeriesIds, removeSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const onEdit = () => {
removeSeries(seriesId);
setSeries(NEW_SERIES_KEY, { ...series });
setSeries(seriesId, { ...series, isNew: true });
};
const copySeries = () => {
let copySeriesId: string = `${seriesId}-copy`;
if (allSeriesIds.includes(copySeriesId)) {
copySeriesId = copySeriesId + allSeriesIds.length;
}
setSeries(copySeriesId, series);
};
const { reportType, reportDefinitions, isNew, ...restSeries } = series;
const isSaveAble = reportType && !isEmpty(reportDefinitions);
const saveSeries = () => {
if (isSaveAble) {
const reportDefId = Object.values(reportDefinitions ?? {})[0];
let newSeriesId = `${reportDefId}-${reportType}`;
if (allSeriesIds.includes(newSeriesId)) {
newSeriesId = `${newSeriesId}-${allSeriesIds.length}`;
}
const newSeriesN: SeriesUrl = {
...restSeries,
reportType,
reportDefinitions,
};
setSeries(newSeriesId, newSeriesN);
removeSeries(seriesId);
}
};
return (
<EuiFlexGroup alignItems="center" gutterSize="xs" justifyContent="center">
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType={'documentEdit'}
aria-label={i18n.translate('xpack.observability.seriesEditor.edit', {
defaultMessage: 'Edit series',
})}
size="m"
onClick={onEdit}
/>
</EuiFlexItem>
<EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="center">
{!editorMode && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="documentEdit"
aria-label={i18n.translate('xpack.observability.seriesEditor.edit', {
defaultMessage: 'Edit series',
})}
size="s"
onClick={onEdit}
/>
</EuiFlexItem>
)}
{editorMode && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType={'save'}
aria-label={i18n.translate('xpack.observability.seriesEditor.save', {
defaultMessage: 'Save series',
})}
size="s"
onClick={saveSeries}
color="success"
isDisabled={!isSaveAble}
/>
</EuiFlexItem>
)}
{editorMode && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType={'copy'}
aria-label={i18n.translate('xpack.observability.seriesEditor.clone', {
defaultMessage: 'Copy series',
})}
size="s"
onClick={copySeries}
/>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<RemoveSeries seriesId={seriesId} />
</EuiFlexItem>

View file

@ -16,7 +16,7 @@ describe('SelectedFilters', function () {
mockAppIndexPattern();
const dataViewSeries = getDefaultConfigs({
reportType: 'dist',
reportType: 'data-distribution',
indexPattern: mockIndexPattern,
dataType: 'ux',
});

View file

@ -39,7 +39,7 @@ export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props)
const { removeFilter } = useSeriesFilters({ seriesId });
const { indexPattern } = useAppIndexPatternContext();
const { indexPattern } = useAppIndexPatternContext(series.dataType);
return (filters.length > 0 || definitionFilters.length > 0) && indexPattern ? (
<EuiFlexItem>
@ -55,6 +55,7 @@ export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props)
value={val}
removeFilter={() => removeFilter({ field, value: val, negate: false })}
negate={false}
indexPattern={indexPattern}
/>
</EuiFlexItem>
))}
@ -67,6 +68,7 @@ export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props)
value={val}
negate={true}
removeFilter={() => removeFilter({ field, value: val, negate: true })}
indexPattern={indexPattern}
/>
</EuiFlexItem>
))}
@ -87,6 +89,7 @@ export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props)
}}
negate={false}
definitionFilter={true}
indexPattern={indexPattern}
/>
</EuiFlexItem>
))}

View file

@ -24,7 +24,7 @@ interface EditItem {
}
export function SeriesEditor() {
const { allSeries, firstSeriesId } = useSeriesStorage();
const { allSeries, allSeriesIds } = useSeriesStorage();
const columns = [
{
@ -33,80 +33,77 @@ export function SeriesEditor() {
}),
field: 'id',
width: '15%',
render: (val: string) => (
render: (seriesId: string) => (
<EuiText>
<EuiIcon type="dot" color="green" size="l" />{' '}
{val === NEW_SERIES_KEY ? 'series-preview' : val}
{seriesId === NEW_SERIES_KEY ? 'series-preview' : seriesId}
</EuiText>
),
},
...(firstSeriesId !== NEW_SERIES_KEY
? [
{
name: i18n.translate('xpack.observability.expView.seriesEditor.filters', {
defaultMessage: 'Filters',
}),
field: 'defaultFilters',
width: '15%',
render: (defaultFilters: string[], { id, seriesConfig }: EditItem) => (
<SeriesFilter
defaultFilters={defaultFilters}
seriesId={id}
series={seriesConfig}
filters={seriesConfig.filters}
/>
),
},
{
name: i18n.translate('xpack.observability.expView.seriesEditor.breakdowns', {
defaultMessage: 'Breakdowns',
}),
field: 'breakdowns',
width: '25%',
render: (val: string[], item: EditItem) => (
<ChartEditOptions seriesId={item.id} breakdowns={val} series={item.seriesConfig} />
),
},
{
name: (
<div>
<FormattedMessage
id="xpack.observability.expView.seriesEditor.time"
defaultMessage="Time"
/>
</div>
),
width: '20%',
field: 'id',
align: 'right' as const,
render: (val: string, item: EditItem) => <DatePickerCol seriesId={item.id} />,
},
{
name: i18n.translate('xpack.observability.expView.seriesEditor.actions', {
defaultMessage: 'Actions',
}),
align: 'center' as const,
width: '10%',
field: 'id',
render: (val: string, item: EditItem) => <SeriesActions seriesId={item.id} />,
},
]
: []),
{
name: i18n.translate('xpack.observability.expView.seriesEditor.filters', {
defaultMessage: 'Filters',
}),
field: 'defaultFilters',
width: '15%',
render: (seriesId: string, { seriesConfig, id }: EditItem) => (
<SeriesFilter
defaultFilters={seriesConfig.defaultFilters}
seriesId={id}
series={seriesConfig}
filters={seriesConfig.filters}
/>
),
},
{
name: i18n.translate('xpack.observability.expView.seriesEditor.breakdowns', {
defaultMessage: 'Breakdowns',
}),
field: 'id',
width: '25%',
render: (seriesId: string, { seriesConfig, id }: EditItem) => (
<ChartEditOptions
seriesId={id}
breakdowns={seriesConfig.breakdowns}
series={seriesConfig}
/>
),
},
{
name: (
<div>
<FormattedMessage
id="xpack.observability.expView.seriesEditor.time"
defaultMessage="Time"
/>
</div>
),
width: '20%',
field: 'id',
align: 'right' as const,
render: (seriesId: string, item: EditItem) => <DatePickerCol seriesId={seriesId} />,
},
{
name: i18n.translate('xpack.observability.expView.seriesEditor.actions', {
defaultMessage: 'Actions',
}),
align: 'center' as const,
width: '10%',
field: 'id',
render: (seriesId: string, item: EditItem) => <SeriesActions seriesId={seriesId} />,
},
];
const allSeriesKeys = Object.keys(allSeries);
const { indexPatterns } = useAppIndexPatternContext();
const items: EditItem[] = [];
const { indexPattern } = useAppIndexPatternContext();
allSeriesKeys.forEach((seriesKey) => {
allSeriesIds.forEach((seriesKey) => {
const series = allSeries[seriesKey];
if (series.reportType && indexPattern) {
if (series?.reportType && indexPatterns[series.dataType] && !series.isNew) {
items.push({
id: seriesKey,
seriesConfig: getDefaultConfigs({
indexPattern,
indexPattern: indexPatterns[series.dataType],
reportType: series.reportType,
dataType: series.dataType,
}),
@ -114,6 +111,10 @@ export function SeriesEditor() {
}
});
if (items.length === 0 && allSeriesIds.length > 0) {
return null;
}
return (
<>
<EuiSpacer />
@ -121,8 +122,7 @@ export function SeriesEditor() {
items={items}
rowHeader="firstName"
columns={columns}
rowProps={() => (firstSeriesId === NEW_SERIES_KEY ? {} : { height: 100 })}
noItemsMessage={i18n.translate('xpack.observability.expView.seriesEditor.notFound', {
noItemsMessage={i18n.translate('xpack.observability.expView.seriesEditor.seriesNotFound', {
defaultMessage: 'No series found, please add a series.',
})}
cellProps={{

View file

@ -23,7 +23,7 @@ export const ReportViewTypes = {
dist: 'data-distribution',
kpi: 'kpi-over-time',
cwv: 'core-web-vitals',
mdd: 'mobile-device-distribution',
mdd: 'device-data-distribution',
} as const;
type ValueOf<T> = T[keyof T];
@ -56,7 +56,6 @@ export interface DataSeries {
reportType: ReportViewType;
xAxisColumn: Partial<LastValueIndexPatternColumn> | Partial<DateHistogramIndexPatternColumn>;
yAxisColumns: Array<Partial<FieldBasedIndexPatternColumn>>;
breakdowns: string[];
defaultSeriesType: SeriesType;
defaultFilters: Array<string | { field: string; nested?: string; isNegated?: boolean }>;
@ -80,10 +79,11 @@ export interface SeriesUrl {
breakdown?: string;
filters?: UrlFilter[];
seriesType?: SeriesType;
reportType: ReportViewTypeId;
reportType: ReportViewType;
operationType?: OperationType;
dataType: AppDataType;
reportDefinitions?: URLReportDefinition;
isNew?: boolean;
}
export interface UrlFilter {
@ -94,6 +94,7 @@ export interface UrlFilter {
export interface ConfigProps {
indexPattern: IIndexPattern;
series?: SeriesUrl;
}
export type AppDataType = 'synthetics' | 'ux' | 'infra_logs' | 'infra_metrics' | 'apm' | 'mobile';

View file

@ -0,0 +1,148 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { urlFiltersToKueryString } from './stringify_kueries';
import { UrlFilter } from '../types';
import { USER_AGENT_NAME } from '../configurations/constants/elasticsearch_fieldnames';
describe('stringifyKueries', () => {
let filters: UrlFilter[];
beforeEach(() => {
filters = [
{
field: USER_AGENT_NAME,
values: ['Chrome', 'Firefox'],
notValues: [],
},
];
});
it('stringifies the current values', () => {
expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot(
`"user_agent.name: (\\"Chrome\\" or \\"Firefox\\")"`
);
});
it('correctly stringifies a single value', () => {
filters = [
{
field: USER_AGENT_NAME,
values: ['Chrome'],
notValues: [],
},
];
expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot(
`"user_agent.name: (\\"Chrome\\")"`
);
});
it('returns an empty string for an empty array', () => {
expect(urlFiltersToKueryString([])).toMatchInlineSnapshot(`""`);
});
it('returns an empty string for an empty value', () => {
filters = [
{
field: USER_AGENT_NAME,
values: [],
notValues: [],
},
];
expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot(`""`);
});
it('adds quotations if the value contains a space', () => {
filters = [
{
field: USER_AGENT_NAME,
values: ['Google Chrome'],
notValues: [],
},
];
expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot(
`"user_agent.name: (\\"Google Chrome\\")"`
);
});
it('adds quotations inside parens if there are values containing spaces', () => {
filters = [
{
field: USER_AGENT_NAME,
values: ['Google Chrome'],
notValues: ['Apple Safari'],
},
];
expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot(
`"user_agent.name: (\\"Google Chrome\\") and not (user_agent.name: (\\"Apple Safari\\"))"`
);
});
it('handles parens for values with greater than 2 items', () => {
filters = [
{
field: USER_AGENT_NAME,
values: ['Chrome', 'Firefox', 'Safari', 'Opera'],
notValues: ['Safari'],
},
];
expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot(
`"user_agent.name: (\\"Chrome\\" or \\"Firefox\\" or \\"Safari\\" or \\"Opera\\") and not (user_agent.name: (\\"Safari\\"))"`
);
});
it('handles colon characters in values', () => {
filters = [
{
field: 'url',
values: ['https://elastic.co', 'https://example.com'],
notValues: [],
},
];
expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot(
`"url: (\\"https://elastic.co\\" or \\"https://example.com\\")"`
);
});
it('handles precending empty array', () => {
filters = [
{
field: 'url',
values: ['https://elastic.co', 'https://example.com'],
notValues: [],
},
{
field: USER_AGENT_NAME,
values: [],
},
];
expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot(
`"url: (\\"https://elastic.co\\" or \\"https://example.com\\")"`
);
});
it('handles skipped empty arrays', () => {
filters = [
{
field: 'url',
values: ['https://elastic.co', 'https://example.com'],
notValues: [],
},
{
field: USER_AGENT_NAME,
values: [],
},
{
field: 'url',
values: ['https://elastic.co', 'https://example.com'],
notValues: [],
},
];
expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot(
`"url: (\\"https://elastic.co\\" or \\"https://example.com\\") and url: (\\"https://elastic.co\\" or \\"https://example.com\\")"`
);
});
});

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { UrlFilter } from '../types';
/**
* Extract a map's keys to an array, then map those keys to a string per key.
* The strings contain all of the values chosen for the given field (which is also the key value).
* Reduce the list of query strings to a singular string, with AND operators between.
*/
export const urlFiltersToKueryString = (urlFilters: UrlFilter[]): string => {
let kueryString = '';
urlFilters.forEach(({ field, values, notValues }) => {
const valuesT = values?.map((val) => `"${val}"`);
const notValuesT = notValues?.map((val) => `"${val}"`);
if (valuesT && valuesT?.length > 0) {
if (kueryString.length > 0) {
kueryString += ' and ';
}
kueryString += `${field}: (${valuesT.join(' or ')})`;
}
if (notValuesT && notValuesT?.length > 0) {
if (kueryString.length > 0) {
kueryString += ' and ';
}
kueryString += `not (${field}: (${notValuesT.join(' or ')}))`;
}
});
return kueryString;
};

View file

@ -112,4 +112,18 @@ export const routes = {
}),
},
},
// enable this to test multi series architecture
// '/exploratory-view/multi': {
// handler: () => {
// return <ExploratoryViewPage multiSeries={true} />;
// },
// params: {
// query: t.partial({
// rangeFrom: t.string,
// rangeTo: t.string,
// refreshPaused: jsonRt.pipe(t.boolean),
// refreshInterval: jsonRt.pipe(t.number),
// }),
// },
// },
};

View file

@ -17256,7 +17256,6 @@
"xpack.observability.expView.seriesEditor.clearFilter": "フィルターを消去",
"xpack.observability.expView.seriesEditor.filters": "フィルター",
"xpack.observability.expView.seriesEditor.name": "名前",
"xpack.observability.expView.seriesEditor.notFound": "系列が見つかりません。系列を追加してください。",
"xpack.observability.expView.seriesEditor.removeSeries": "クリックすると、系列を削除します",
"xpack.observability.expView.seriesEditor.time": "時間",
"xpack.observability.featureCatalogueDescription": "専用UIで、ログ、メトリック、アプリケーショントレース、システム可用性を連結します。",

View file

@ -17492,7 +17492,6 @@
"xpack.observability.expView.seriesEditor.clearFilter": "清除筛选",
"xpack.observability.expView.seriesEditor.filters": "筛选",
"xpack.observability.expView.seriesEditor.name": "名称",
"xpack.observability.expView.seriesEditor.notFound": "未找到序列,请添加序列。",
"xpack.observability.expView.seriesEditor.removeSeries": "单击移除序列",
"xpack.observability.expView.seriesEditor.time": "时间",
"xpack.observability.featureCatalogueDescription": "通过专用 UI 整合您的日志、指标、应用程序跟踪和系统可用性。",

View file

@ -191,7 +191,7 @@ export const PingHistogramComponent: React.FC<PingHistogramComponentProps> = ({
{
'pings-over-time': {
dataType: 'synthetics',
reportType: 'kpi',
reportType: 'kpi-over-time',
time: { from: dateRangeStart, to: dateRangeEnd },
...(monitorId ? { filters: [{ field: 'monitor.id', values: [monitorId] }] } : {}),
},

View file

@ -40,10 +40,11 @@ export function ActionMenuContent(): React.ReactElement {
const syntheticExploratoryViewLink = createExploratoryViewUrl(
{
'synthetics-series': {
'synthetics-series': ({
dataType: 'synthetics',
isNew: true,
time: { from: dateRangeStart, to: dateRangeEnd },
} as SeriesUrl,
} as unknown) as SeriesUrl,
},
basePath
);

View file

@ -56,7 +56,7 @@ export const MonitorDuration: React.FC<MonitorIdParam> = ({ monitorId }) => {
const exploratoryViewLink = createExploratoryViewUrl(
{
[`monitor-duration`]: {
reportType: 'kpi',
reportType: 'kpi-over-time',
time: { from: dateRangeStart, to: dateRangeEnd },
reportDefinitions: {
'monitor.id': [monitorId] as string[],