[Lens][TSVB] Ad-hoc dataViews for index pattern string mode in TSVB. (#143500)

## Summary
Completes part of https://github.com/elastic/kibana/issues/138236.
Added support of ad-hoc dataViews while converting TSVB visualizations,
when index pattern string mode is turned on.

Co-authored-by: Uladzislau Lasitsa <vlad.lasitsa@gmail.com>
Co-authored-by: Joe Reuter <johannes.reuter@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yaroslav Kuznietsov 2022-11-09 17:41:36 +02:00 committed by GitHub
parent 452b81f0e7
commit 3d7b01e28b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1019 additions and 712 deletions

View file

@ -13,7 +13,7 @@ import type { Panel, IndexPatternValue, FetchedIndexPattern } from './types';
export const isStringTypeIndexPattern = (
indexPatternValue: IndexPatternValue
): indexPatternValue is string => typeof indexPatternValue === 'string';
): indexPatternValue is string => typeof indexPatternValue === 'string' && indexPatternValue !== '';
export const isDataViewTypeIndexPattern = (
indexPatternValue: IndexPatternValue

View file

@ -6,13 +6,16 @@
* Side Public License, v 1.
*/
import React, { useCallback, useState, useEffect } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import { i18n } from '@kbn/i18n';
import { EuiFieldText, EuiFieldTextProps } from '@elastic/eui';
import { EuiFieldText, EuiFieldTextProps, EuiButtonIcon } from '@elastic/eui';
import { SwitchModePopover } from './switch_mode_popover';
import type { SelectIndexComponentProps } from './types';
const updateIndexText = i18n.translate('visTypeTimeseries.indexPatternSelect.updateIndex', {
defaultMessage: 'Update visualization with entered data view',
});
export const FieldTextSelect = ({
fetchedIndex,
onIndexChange,
@ -35,15 +38,30 @@ export const FieldTextSelect = ({
}
}, [indexPatternString, inputValue]);
useDebounce(
() => {
if ((inputValue ?? '') !== (indexPatternString ?? '')) {
onIndexChange(inputValue);
}
},
150,
[inputValue, onIndexChange]
);
const updateIndex = useCallback(() => {
if ((inputValue ?? '') !== (indexPatternString ?? '')) {
onIndexChange(inputValue);
}
}, [onIndexChange, inputValue, indexPatternString]);
const appends = [
<EuiButtonIcon
aria-label={updateIndexText}
iconType="play"
onClick={updateIndex}
disabled={inputValue === indexPatternString}
/>,
];
if (allowSwitchMode) {
appends.push(
<SwitchModePopover
onModeChange={onModeChange}
fetchedIndex={fetchedIndex}
useKibanaIndices={false}
/>
);
}
return (
<EuiFieldText
@ -52,15 +70,7 @@ export const FieldTextSelect = ({
value={inputValue ?? ''}
placeholder={placeholder}
data-test-subj={dataTestSubj}
{...(allowSwitchMode && {
append: (
<SwitchModePopover
onModeChange={onModeChange}
fetchedIndex={fetchedIndex}
useKibanaIndices={false}
/>
),
})}
append={appends}
/>
);
};

View file

@ -22,7 +22,7 @@ const mockIsValidMetrics = jest.fn();
const mockGetDatasourceValue = jest
.fn()
.mockImplementation(() => Promise.resolve(stubLogstashDataView));
const mockGetDataSourceInfo = jest.fn();
const mockExtractOrGenerateDatasourceInfo = jest.fn();
const mockGetSeriesAgg = jest.fn();
jest.mock('../../services', () => ({
@ -50,7 +50,7 @@ jest.mock('../lib/metrics', () => {
});
jest.mock('../lib/datasource', () => ({
getDataSourceInfo: jest.fn(() => mockGetDataSourceInfo()),
extractOrGenerateDatasourceInfo: jest.fn(() => mockExtractOrGenerateDatasourceInfo()),
}));
describe('convertToLens', () => {
@ -77,7 +77,7 @@ describe('convertToLens', () => {
beforeEach(() => {
mockIsValidMetrics.mockReturnValue(true);
mockGetDataSourceInfo.mockReturnValue({
mockExtractOrGenerateDatasourceInfo.mockReturnValue({
indexPatternId: 'test-index-pattern',
timeField: 'timeField',
indexPattern: { id: 'test-index-pattern' },
@ -126,7 +126,7 @@ describe('convertToLens', () => {
},
} as Vis<Panel>);
expect(result).toBeNull();
expect(mockGetDataSourceInfo).toBeCalledTimes(0);
expect(mockExtractOrGenerateDatasourceInfo).toBeCalledTimes(0);
});
test('should return null if only series agg is specified', async () => {
@ -177,4 +177,31 @@ describe('convertToLens', () => {
expect(result).toBeDefined();
expect(result?.type).toBe('lnsMetric');
});
test('should drop adhoc dataviews if action is required', async () => {
mockGetMetricsColumns.mockReturnValue([metricColumn]);
mockGetSeriesAgg.mockReturnValue({ metrics: [metric] });
mockGetConfigurationForGauge.mockReturnValue({});
const result = await convertToLens(
{
params: createPanel({
series: [
createSeries({
metrics: [{ id: 'some-id', type: METRIC_TYPES.AVG, field: 'test-field' }],
hidden: false,
}),
createSeries({
metrics: [{ id: 'some-id', type: METRIC_TYPES.AVG, field: 'test-field' }],
hidden: false,
}),
],
}),
} as Vis<Panel>,
undefined,
true
);
expect(result).toBeDefined();
expect(result?.type).toBe('lnsMetric');
});
});

View file

@ -16,7 +16,7 @@ import {
import { PANEL_TYPES, TSVB_METRIC_TYPES } from '../../../common/enums';
import { Metric } from '../../../common/types';
import { getDataViewsStart } from '../../services';
import { getDataSourceInfo } from '../lib/datasource';
import { extractOrGenerateDatasourceInfo } from '../lib/datasource';
import { getMetricsColumns, getBucketsColumns } from '../lib/series';
import { getConfigurationForGauge as getConfiguration } from '../lib/configurations/metric';
import {
@ -45,98 +45,107 @@ const getMaxFormula = (metric: Metric, column?: Column) => {
}))`;
};
const invalidModelError = () => new Error('Invalid model');
export const convertToLens: ConvertTsvbToLensVisualization = async (
{ params: model },
timeRange
) => {
const dataViews = getDataViewsStart();
try {
const series = model.series[0];
// not valid time shift
if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') {
throw invalidModelError();
}
const series = model.series[0];
// not valid time shift
if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') {
if (!isValidMetrics(series.metrics, PANEL_TYPES.GAUGE, series.time_range_mode)) {
throw invalidModelError();
}
if (series.metrics[series.metrics.length - 1].type === TSVB_METRIC_TYPES.STATIC) {
throw invalidModelError();
}
const reducedTimeRange = getReducedTimeRange(model, series, timeRange);
const datasourceInfo = await extractOrGenerateDatasourceInfo(
model.index_pattern,
model.time_field,
Boolean(series.override_index_pattern),
series.series_index_pattern,
series.series_time_field,
dataViews
);
if (!datasourceInfo) {
throw invalidModelError();
}
const { indexPatternId, indexPattern } = datasourceInfo;
// handle multiple metrics
const metricsColumns = getMetricsColumns(series, indexPattern!, model.series.length, {
reducedTimeRange,
});
if (metricsColumns === null) {
throw invalidModelError();
}
const bucketsColumns = getBucketsColumns(model, series, metricsColumns, indexPattern!, false);
if (bucketsColumns === null) {
throw invalidModelError();
}
const [bucket] = bucketsColumns;
const extendedLayer: ExtendedLayer = {
indexPatternId,
layerId: uuid(),
columns: [...metricsColumns, ...(bucket ? [bucket] : [])],
columnOrder: [],
};
const primarySeries = model.series[0];
const primaryMetricWithCollapseFn = getMetricWithCollapseFn(primarySeries);
if (!primaryMetricWithCollapseFn || !primaryMetricWithCollapseFn.metric) {
throw invalidModelError();
}
const primaryColumn = findMetricColumn(
primaryMetricWithCollapseFn.metric,
extendedLayer.columns
);
if (!primaryColumn) {
throw invalidModelError();
}
let gaugeMaxColumn: StaticValueColumn | FormulaColumn | null = createFormulaColumnWithoutMeta(
getMaxFormula(primaryMetricWithCollapseFn.metric, primaryColumn)
);
if (model.gauge_max !== undefined && model.gauge_max !== '') {
gaugeMaxColumn = createStaticValueColumn(model.gauge_max);
}
const layer = {
...extendedLayer,
columns: [...extendedLayer.columns, gaugeMaxColumn],
};
const configuration = getConfiguration(model, layer, bucket, gaugeMaxColumn ?? undefined);
if (!configuration) {
throw invalidModelError();
}
const layers = Object.values(excludeMetaFromLayers({ 0: layer }));
return {
type: 'lnsMetric',
layers,
configuration,
indexPatternIds: getIndexPatternIds(layers),
};
} catch (e) {
return null;
}
if (!isValidMetrics(series.metrics, PANEL_TYPES.GAUGE, series.time_range_mode)) {
return null;
}
if (series.metrics[series.metrics.length - 1].type === TSVB_METRIC_TYPES.STATIC) {
return null;
}
const reducedTimeRange = getReducedTimeRange(model, series, timeRange);
const datasourceInfo = await getDataSourceInfo(
model.index_pattern,
model.time_field,
Boolean(series.override_index_pattern),
series.series_index_pattern,
series.series_time_field,
dataViews
);
if (!datasourceInfo) {
return null;
}
const { indexPatternId, indexPattern } = datasourceInfo;
// handle multiple metrics
const metricsColumns = getMetricsColumns(series, indexPattern!, model.series.length, {
reducedTimeRange,
});
if (metricsColumns === null) {
return null;
}
const bucketsColumns = getBucketsColumns(model, series, metricsColumns, indexPattern!, false);
if (bucketsColumns === null) {
return null;
}
const [bucket] = bucketsColumns;
const extendedLayer: ExtendedLayer = {
indexPatternId,
layerId: uuid(),
columns: [...metricsColumns, ...(bucket ? [bucket] : [])],
columnOrder: [],
};
const primarySeries = model.series[0];
const primaryMetricWithCollapseFn = getMetricWithCollapseFn(primarySeries);
if (!primaryMetricWithCollapseFn || !primaryMetricWithCollapseFn.metric) {
return null;
}
const primaryColumn = findMetricColumn(primaryMetricWithCollapseFn.metric, extendedLayer.columns);
if (!primaryColumn) {
return null;
}
let gaugeMaxColumn: StaticValueColumn | FormulaColumn | null = createFormulaColumnWithoutMeta(
getMaxFormula(primaryMetricWithCollapseFn.metric, primaryColumn)
);
if (model.gauge_max !== undefined && model.gauge_max !== '') {
gaugeMaxColumn = createStaticValueColumn(model.gauge_max);
}
const layer = {
...extendedLayer,
columns: [...extendedLayer.columns, gaugeMaxColumn],
};
const configuration = getConfiguration(model, layer, bucket, gaugeMaxColumn ?? undefined);
if (!configuration) {
return null;
}
const layers = Object.values(excludeMetaFromLayers({ 0: layer }));
return {
type: 'lnsMetric',
layers,
configuration,
indexPatternIds: getIndexPatternIds(layers),
};
};

View file

@ -48,15 +48,4 @@ describe('convertTSVBtoLensConfiguration', () => {
} as Vis<Panel>);
expect(triggerOptions).toBeNull();
});
test('should return null for a string index pattern', async () => {
const stringIndexPatternModel = {
...model,
use_kibana_indexes: false,
};
const triggerOptions = await convertTSVBtoLensConfiguration({
params: stringIndexPatternModel,
} as Vis<Panel>);
expect(triggerOptions).toBeNull();
});
});

View file

@ -45,10 +45,6 @@ const getConvertFnByType = (type: PANEL_TYPES) => {
* In case of null, the menu item is disabled and the user can't navigate to Lens.
*/
export const convertTSVBtoLensConfiguration = async (vis: Vis<Panel>, timeRange?: TimeRange) => {
// Disables the option for not supported charts, for the string mode and for series with annotations
if (!vis.params.use_kibana_indexes) {
return null;
}
// Disables if model is invalid
if (vis.params.isModelInvalid) {
return null;

View file

@ -20,10 +20,16 @@ import { createPanel, createSeries } from '../../__mocks__';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
const mockExtractOrGenerateDatasourceInfo = jest.fn();
jest.mock('uuid', () => ({
v4: () => 'test-id',
}));
jest.mock('../../datasource', () => ({
extractOrGenerateDatasourceInfo: jest.fn(() => mockExtractOrGenerateDatasourceInfo()),
}));
const mockedIndices = [
{
id: 'test',
@ -31,6 +37,12 @@ const mockedIndices = [
timeFieldName: 'test_field',
getFieldByName: (name: string) => ({ aggregatable: name !== 'host' }),
},
{
id: 'test2',
title: 'test2',
timeFieldName: 'test_field',
getFieldByName: (name: string) => ({ aggregatable: name !== 'host' }),
},
] as unknown as DataView[];
const indexPatternsService = {
@ -356,6 +368,14 @@ describe('getLayers', () => {
],
series: [createSeries({ metrics: staticValueMetric })],
});
beforeEach(() => {
jest.clearAllMocks();
mockExtractOrGenerateDatasourceInfo.mockReturnValue({
indexPattern: mockedIndices[0],
indexPatternId: mockedIndices[0].id,
timeField: mockedIndices[0].timeFieldName,
});
});
test.each<
[
@ -568,9 +588,31 @@ describe('getLayers', () => {
},
],
],
[
'multiple annotations with different data views create separate layers',
[dataSourceLayersWithStatic, panelWithMultiAnnotations, indexPatternsService, false],
])('should return %s', async (_, input, expected) => {
const layers = await getLayers(...input);
expect(layers).toEqual(expected.map(expect.objectContaining));
});
test('should return multiple annotations with different data views create separate layers', async () => {
mockExtractOrGenerateDatasourceInfo.mockReturnValueOnce({
indexPattern: mockedIndices[0],
indexPatternId: mockedIndices[0].id,
timeField: mockedIndices[0].timeFieldName,
});
mockExtractOrGenerateDatasourceInfo.mockReturnValueOnce({
indexPattern: mockedIndices[1],
indexPatternId: mockedIndices[1].id,
timeField: mockedIndices[1].timeFieldName,
});
const layers = await getLayers(
dataSourceLayersWithStatic,
panelWithMultiAnnotations,
indexPatternsService,
false
);
expect(layers).toEqual(
[
{
layerType: 'referenceLine',
@ -634,7 +676,7 @@ describe('getLayers', () => {
type: 'query',
},
],
indexPatternId: 'test',
indexPatternId: 'test2',
},
{
layerId: 'test-id',
@ -659,18 +701,28 @@ describe('getLayers', () => {
type: 'query',
},
],
indexPatternId: 'test2',
indexPatternId: 'test',
},
],
],
[
'annotation layer gets correct dataView when none is defined',
[
dataSourceLayersWithStatic,
panelWithSingleAnnotationDefaultDataView,
indexPatternsService,
false,
],
].map(expect.objectContaining)
);
expect(mockExtractOrGenerateDatasourceInfo).toBeCalledTimes(3);
});
test('should return annotation layer gets correct dataView when none is defined', async () => {
mockExtractOrGenerateDatasourceInfo.mockReturnValue({
indexPattern: { ...mockedIndices[0], id: 'default' },
indexPatternId: 'default',
timeField: mockedIndices[0].timeFieldName,
});
const layers = await getLayers(
dataSourceLayersWithStatic,
panelWithSingleAnnotationDefaultDataView,
indexPatternsService,
false
);
expect(layers).toEqual(
[
{
layerType: 'referenceLine',
@ -712,10 +764,8 @@ describe('getLayers', () => {
],
indexPatternId: 'default',
},
],
],
])('should return %s', async (_, input, expected) => {
const layers = await getLayers(...input);
expect(layers).toEqual(expected.map(expect.objectContaining));
].map(expect.objectContaining)
);
expect(mockExtractOrGenerateDatasourceInfo).toBeCalledTimes(1);
});
});

View file

@ -21,7 +21,6 @@ import { euiLightVars } from '@kbn/ui-theme';
import { groupBy } from 'lodash';
import { DataViewsPublicPluginStart, DataView } from '@kbn/data-plugin/public/data_views';
import { getDefaultQueryLanguage } from '../../../../application/components/lib/get_default_query_language';
import { fetchIndexPattern } from '../../../../../common/index_patterns_utils';
import { ICON_TYPES_MAP } from '../../../../application/visualizations/constants';
import { SUPPORTED_METRICS } from '../../metrics';
import type { Annotation, Metric, Panel, Series } from '../../../../../common/types';
@ -34,6 +33,7 @@ import {
AnyColumnWithReferences,
} from '../../convert';
import { getChartType } from './chart_type';
import { extractOrGenerateDatasourceInfo } from '../../datasource';
export const isColumnWithReference = (column: Column): column is AnyColumnWithReferences =>
Boolean((column as AnyColumnWithReferences).references);
@ -144,22 +144,32 @@ export const getLayers = async (
}
const annotationsByIndexPatternAndIgnoreFlag = groupBy(model.annotations, (a) => {
const id = typeof a.index_pattern === 'object' && 'id' in a.index_pattern && a.index_pattern.id;
return `${id}-${Boolean(a.ignore_global_filters)}`;
const id =
typeof a.index_pattern === 'object' && 'id' in a.index_pattern
? a.index_pattern.id
: a.index_pattern;
return `${id}-${a.time_field ?? ''}-${Boolean(a.ignore_global_filters)}`;
});
try {
const annotationsLayers: Array<XYAnnotationsLayerConfig | undefined> = await Promise.all(
Object.values(annotationsByIndexPatternAndIgnoreFlag).map(async (annotations) => {
const [firstAnnotation] = annotations;
const indexPatternId =
typeof firstAnnotation.index_pattern === 'string'
? firstAnnotation.index_pattern
: firstAnnotation.index_pattern?.id;
const convertedAnnotations: EventAnnotationConfig[] = [];
const { indexPattern } =
(await fetchIndexPattern(indexPatternId && { id: indexPatternId }, dataViews)) || {};
const result = await extractOrGenerateDatasourceInfo(
firstAnnotation.index_pattern,
firstAnnotation.time_field,
false,
undefined,
undefined,
dataViews
);
if (!result) {
throw new Error('Invalid annotation datasource');
}
const { indexPattern } = result;
if (indexPattern) {
annotations.forEach((a: Annotation) => {
const lensAnnotation = convertAnnotation(a, indexPattern);

View file

@ -0,0 +1,170 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { DataView } from '@kbn/data-plugin/common';
import { extractOrGenerateDatasourceInfo } from './datasource_info';
const dataViewsMap: Record<string, DataView> = {
test1: { id: 'test1', title: 'test1', timeFieldName: 'timeField1' } as DataView,
test2: {
id: 'test2',
title: 'test2',
timeFieldName: 'timeField2',
} as DataView,
test3: {
id: 'test3',
title: 'test3',
timeFieldName: 'timeField3',
name: 'index-pattern-3',
} as DataView,
};
const mockCreateDataView = jest.fn();
jest.mock('../../../../common/index_patterns_utils', () => {
const originalModule = jest.requireActual('../../../../common/index_patterns_utils');
return {
isStringTypeIndexPattern: originalModule.isStringTypeIndexPattern,
};
});
const getDataview = async (id: string): Promise<DataView | undefined> => dataViewsMap[id];
describe('extractOrGenerateDatasourceInfo', () => {
let dataViews: DataViewsPublicPluginStart;
beforeAll(() => {
dataViews = {
getDefault: jest.fn(async () => {
return { id: '12345', title: 'default', timeFieldName: '@timestamp' };
}),
get: getDataview,
create: mockCreateDataView,
} as unknown as DataViewsPublicPluginStart;
});
beforeEach(() => {
mockCreateDataView.mockReturnValue(getDataview('test3'));
});
afterEach(() => {
jest.clearAllMocks();
});
test('should return ad-hoc dataview if model_indexpattern is string and no corresponding dataview found by string', async () => {
const timeFieldName = 'timeField-3';
const datasourceInfo = await extractOrGenerateDatasourceInfo(
'test',
timeFieldName,
false,
undefined,
undefined,
dataViews
);
const { indexPatternId, timeField, indexPattern } = datasourceInfo!;
expect(indexPatternId).toBe(dataViewsMap.test3.id);
expect(timeField).toBe(dataViewsMap.test3.timeFieldName);
expect(indexPattern).toBe(dataViewsMap.test3);
});
test('should return dataview if model_indexpattern is string and corresponding dataview is found by string', async () => {
const timeFieldName = 'timeField-3';
const datasourceInfo = await extractOrGenerateDatasourceInfo(
dataViewsMap.test3.name,
timeFieldName,
false,
undefined,
undefined,
dataViews
);
const { indexPatternId, timeField, indexPattern } = datasourceInfo!;
expect(indexPatternId).toBe(dataViewsMap.test3.id);
expect(timeField).toBe(dataViewsMap.test3.timeFieldName);
expect(indexPattern).toBe(dataViewsMap.test3);
});
test('should return the correct dataview if model_indexpattern is object', async () => {
const datasourceInfo = await extractOrGenerateDatasourceInfo(
{ id: 'dataview-1-id' },
'timeField-1',
false,
undefined,
undefined,
dataViews
);
const { indexPatternId, timeField } = datasourceInfo!;
expect(indexPatternId).toBe('dataview-1-id');
expect(timeField).toBe('timeField-1');
});
test('should fetch the correct data if overwritten dataview is provided', async () => {
const datasourceInfo = await extractOrGenerateDatasourceInfo(
{ id: 'dataview-1-id' },
'timeField-1',
true,
{ id: 'test2' },
undefined,
dataViews
);
const { indexPatternId, timeField } = datasourceInfo!;
expect(indexPatternId).toBe('test2');
expect(timeField).toBe(dataViewsMap.test2.timeFieldName);
});
test('should return the correct dataview if overwritten dataview is string', async () => {
mockCreateDataView.mockReturnValue(dataViewsMap.test2);
const datasourceInfo = await extractOrGenerateDatasourceInfo(
{ id: 'dataview-1-id' },
'timeField-1',
true,
'test2',
undefined,
dataViews
);
const { indexPatternId, timeField } = datasourceInfo!;
expect(indexPatternId).toBe('test2');
expect(timeField).toBe('timeField2');
});
test('should return null if dataview is string and invalid', async () => {
mockCreateDataView.mockImplementationOnce(() => {
throw new Error();
});
const datasourceInfo = await extractOrGenerateDatasourceInfo(
'dataview-1-i',
'timeField-1',
false,
undefined,
undefined,
dataViews
);
expect(datasourceInfo).toBeNull();
});
test('should return null if overritten dataview is string and invalid', async () => {
mockCreateDataView.mockImplementationOnce(() => {
throw new Error();
});
const datasourceInfo = await extractOrGenerateDatasourceInfo(
{ id: 'dataview-1-id' },
'timeField-1',
true,
'test',
undefined,
dataViews
);
expect(datasourceInfo).toBeNull();
});
});

View file

@ -0,0 +1,127 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { isStringTypeIndexPattern } from '../../../../common/index_patterns_utils';
import type { IndexPatternValue } from '../../../../common/types';
const getOverwrittenIndexPattern = async (
overwrittenIndexPattern: IndexPatternValue,
overwrittenTimeField: string | undefined,
dataViews: DataViewsPublicPluginStart
) => {
if (isStringTypeIndexPattern(overwrittenIndexPattern)) {
const indexPattern = await dataViews.create(
{
id: `tsvb_ad_hoc_${overwrittenIndexPattern}${
overwrittenTimeField ? '/' + overwrittenTimeField : ''
}`,
title: overwrittenIndexPattern,
timeFieldName: overwrittenTimeField,
},
false,
false
);
const indexPatternId = indexPattern.id ?? '';
const timeField = indexPattern.timeFieldName;
return { indexPattern, indexPatternId, timeField };
} else if (overwrittenIndexPattern) {
const indexPattern = await dataViews.get(overwrittenIndexPattern.id);
if (indexPattern) {
const indexPatternId = indexPattern.id ?? '';
const timeField = overwrittenTimeField ?? indexPattern.timeFieldName;
return { indexPattern, indexPatternId, timeField };
}
}
return null;
};
const getSelectedIndexPattern = async (
selectedIndexPattern: IndexPatternValue,
selectedTimeField: string | undefined,
dataViews: DataViewsPublicPluginStart
) => {
if (isStringTypeIndexPattern(selectedIndexPattern)) {
if (!selectedTimeField) {
throw new Error('Time field is empty');
}
const indexPattern = await dataViews.create(
{
id: `tsvb_ad_hoc_${selectedIndexPattern}${
selectedTimeField ? '/' + selectedTimeField : ''
}`,
title: selectedIndexPattern,
timeFieldName: selectedTimeField,
},
false,
false
);
const indexPatternId = indexPattern.id ?? '';
return { indexPattern, indexPatternId, timeField: indexPattern.timeFieldName };
}
const indexPattern = await dataViews.getDefault();
const indexPatternId = indexPattern?.id ?? '';
const timeField = indexPattern?.timeFieldName;
return { indexPattern, indexPatternId, timeField };
};
export const extractOrGenerateDatasourceInfo = async (
currentIndexPattern: IndexPatternValue,
currentTimeField: string | undefined,
isOverwritten: boolean,
overwrittenIndexPattern: IndexPatternValue | undefined,
overwrittenTimeField: string | undefined,
dataViews: DataViewsPublicPluginStart
) => {
try {
let indexPatternId =
currentIndexPattern && !isStringTypeIndexPattern(currentIndexPattern)
? currentIndexPattern.id
: '';
let timeField = currentTimeField;
let indexPattern: DataView | null | undefined;
// handle override index pattern
if (isOverwritten) {
const result = await getOverwrittenIndexPattern(
overwrittenIndexPattern,
overwrittenTimeField,
dataViews
);
if (result) {
[indexPattern, indexPatternId, timeField] = [
result.indexPattern,
result.indexPatternId,
result.timeField,
];
}
}
if (!indexPatternId) {
const result = await getSelectedIndexPattern(
currentIndexPattern,
currentTimeField,
dataViews
);
[indexPattern, indexPatternId, timeField] = [
result.indexPattern,
result.indexPatternId,
result.timeField,
];
} else {
indexPattern = await dataViews.get(indexPatternId);
if (!timeField) {
timeField = indexPattern.timeFieldName;
}
}
return { indexPatternId, timeField, indexPattern };
} catch (e) {
return null;
}
};

View file

@ -1,78 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { DataView } from '@kbn/data-plugin/common';
import { getDataSourceInfo } from './get_datasource_info';
const dataViewsMap: Record<string, DataView> = {
test1: { id: 'test1', title: 'test1', timeFieldName: 'timeField1' } as DataView,
test2: {
id: 'test2',
title: 'test2',
timeFieldName: 'timeField2',
} as DataView,
test3: { id: 'test3', title: 'test3', timeFieldName: 'timeField3' } as DataView,
};
const getDataview = async (id: string): Promise<DataView | undefined> => dataViewsMap[id];
describe('getDataSourceInfo', () => {
let dataViews: DataViewsPublicPluginStart;
beforeAll(() => {
dataViews = {
getDefault: jest.fn(async () => {
return { id: '12345', title: 'default', timeFieldName: '@timestamp' };
}),
get: getDataview,
} as unknown as DataViewsPublicPluginStart;
});
test('should return the default dataview if model_indexpattern is string', async () => {
const datasourceInfo = await getDataSourceInfo(
'test',
undefined,
false,
undefined,
undefined,
dataViews
);
const { indexPatternId, timeField } = datasourceInfo!;
expect(indexPatternId).toBe('12345');
expect(timeField).toBe('@timestamp');
});
test('should return the correct dataview if model_indexpattern is object', async () => {
const datasourceInfo = await getDataSourceInfo(
{ id: 'dataview-1-id' },
'timeField-1',
false,
undefined,
undefined,
dataViews
);
const { indexPatternId, timeField } = datasourceInfo!;
expect(indexPatternId).toBe('dataview-1-id');
expect(timeField).toBe('timeField-1');
});
test('should fetch the correct data if overwritten dataview is provided', async () => {
const datasourceInfo = await getDataSourceInfo(
{ id: 'dataview-1-id' },
'timeField-1',
true,
{ id: 'test2' },
undefined,
dataViews
);
const { indexPatternId, timeField } = datasourceInfo!;
expect(indexPatternId).toBe('test2');
expect(timeField).toBe('timeField2');
});
});

View file

@ -1,59 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import {
fetchIndexPattern,
isStringTypeIndexPattern,
} from '../../../../common/index_patterns_utils';
import type { IndexPatternValue } from '../../../../common/types';
export const getDataSourceInfo = async (
modelIndexPattern: IndexPatternValue,
modelTimeField: string | undefined,
isOverwritten: boolean,
overwrittenIndexPattern: IndexPatternValue | undefined,
seriesTimeField: string | undefined,
dataViews: DataViewsPublicPluginStart
) => {
try {
let indexPatternId =
modelIndexPattern && !isStringTypeIndexPattern(modelIndexPattern) ? modelIndexPattern.id : '';
let timeField = modelTimeField;
let indexPattern: DataView | null | undefined;
// handle override index pattern
if (isOverwritten) {
const fetchedIndexPattern = await fetchIndexPattern(overwrittenIndexPattern, dataViews);
indexPattern = fetchedIndexPattern.indexPattern;
if (indexPattern) {
indexPatternId = indexPattern.id ?? '';
timeField = seriesTimeField ?? indexPattern.timeFieldName;
}
}
if (!indexPatternId) {
indexPattern = await dataViews.getDefault();
indexPatternId = indexPattern?.id ?? '';
timeField = indexPattern?.timeFieldName;
} else {
indexPattern = await dataViews.get(indexPatternId);
if (!timeField) {
timeField = indexPattern.timeFieldName;
}
}
return {
indexPatternId,
timeField,
indexPattern,
};
} catch (e) {
return null;
}
};

View file

@ -6,4 +6,4 @@
* Side Public License, v 1.
*/
export * from './get_datasource_info';
export * from './datasource_info';

View file

@ -20,7 +20,7 @@ const mockIsValidMetrics = jest.fn();
const mockGetDatasourceValue = jest
.fn()
.mockImplementation(() => Promise.resolve(stubLogstashDataView));
const mockGetDataSourceInfo = jest.fn();
const mockExtractOrGenerateDatasourceInfo = jest.fn();
jest.mock('../../services', () => ({
getDataViewsStart: jest.fn(() => mockGetDatasourceValue),
@ -41,7 +41,7 @@ jest.mock('../lib/metrics', () => ({
}));
jest.mock('../lib/datasource', () => ({
getDataSourceInfo: jest.fn(() => mockGetDataSourceInfo()),
extractOrGenerateDatasourceInfo: jest.fn(() => mockExtractOrGenerateDatasourceInfo()),
}));
describe('convertToLens', () => {
@ -125,7 +125,7 @@ describe('convertToLens', () => {
beforeEach(() => {
mockIsValidMetrics.mockReturnValue(true);
mockGetDataSourceInfo.mockReturnValue({
mockExtractOrGenerateDatasourceInfo.mockReturnValue({
indexPatternId: 'test-index-pattern',
timeField: 'timeField',
indexPattern: { id: 'test-index-pattern' },
@ -168,6 +168,14 @@ describe('convertToLens', () => {
expect(mockGetConfigurationForMetric).toBeCalledTimes(1);
});
test('should drop adhoc dataviews if action is required', async () => {
const result = await convertToLens(vis, undefined, true);
expect(result).toBeDefined();
expect(result?.type).toBe('lnsMetric');
expect(mockGetBucketsColumns).toBeCalledTimes(model.series.length);
expect(mockGetConfigurationForMetric).toBeCalledTimes(1);
});
test('should skip hidden series', async () => {
const result = await convertToLens({
params: createPanel({
@ -185,7 +193,7 @@ describe('convertToLens', () => {
});
test('should return null if multiple indexPatterns are provided', async () => {
mockGetDataSourceInfo.mockReturnValueOnce({
mockExtractOrGenerateDatasourceInfo.mockReturnValueOnce({
indexPatternId: 'test-index-pattern-1',
timeField: 'timeField',
indexPattern: { id: 'test-index-pattern-1' },

View file

@ -11,7 +11,7 @@ import { DataView, parseTimeShift } from '@kbn/data-plugin/common';
import { getIndexPatternIds } from '@kbn/visualizations-plugin/common/convert_to_lens';
import { PANEL_TYPES } from '../../../common/enums';
import { getDataViewsStart } from '../../services';
import { getDataSourceInfo } from '../lib/datasource';
import { extractOrGenerateDatasourceInfo } from '../lib/datasource';
import { getMetricsColumns, getBucketsColumns } from '../lib/series';
import { getConfigurationForMetric as getConfiguration } from '../lib/configurations/metric';
import { getReducedTimeRange, isValidMetrics } from '../lib/metrics';
@ -22,113 +22,121 @@ import { excludeMetaFromLayers, getUniqueBuckets } from '../utils';
const MAX_SERIES = 2;
const MAX_BUCKETS = 2;
const invalidModelError = () => new Error('Invalid model');
export const convertToLens: ConvertTsvbToLensVisualization = async (
{ params: model },
timeRange
) => {
const dataViews = getDataViewsStart();
const seriesNum = model.series.filter((series) => !series.hidden).length;
try {
const seriesNum = model.series.filter((series) => !series.hidden).length;
const indexPatternIds = new Set();
// we should get max only 2 series
const visibleSeries = model.series.filter(({ hidden }) => !hidden).slice(0, 2);
let currentIndexPattern: DataView | null = null;
for (const series of visibleSeries) {
const datasourceInfo = await getDataSourceInfo(
model.index_pattern,
model.time_field,
Boolean(series.override_index_pattern),
series.series_index_pattern,
series.series_time_field,
dataViews
);
const indexPatternIds = new Set();
// we should get max only 2 series
const visibleSeries = model.series.filter(({ hidden }) => !hidden).slice(0, 2);
let currentIndexPattern: DataView | null = null;
for (const series of visibleSeries) {
const datasourceInfo = await extractOrGenerateDatasourceInfo(
model.index_pattern,
model.time_field,
Boolean(series.override_index_pattern),
series.series_index_pattern,
series.series_time_field,
dataViews
);
if (!datasourceInfo) {
return null;
if (!datasourceInfo) {
throw invalidModelError();
}
const { indexPatternId, indexPattern } = datasourceInfo;
indexPatternIds.add(indexPatternId);
currentIndexPattern = indexPattern;
}
const { indexPatternId, indexPattern } = datasourceInfo;
indexPatternIds.add(indexPatternId);
currentIndexPattern = indexPattern;
}
if (indexPatternIds.size > 1) {
throw invalidModelError();
}
if (indexPatternIds.size > 1) {
const [indexPatternId] = indexPatternIds.values();
const buckets = [];
const metrics = [];
// handle multiple layers/series
for (const series of visibleSeries) {
// not valid time shift
if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') {
throw invalidModelError();
}
if (!isValidMetrics(series.metrics, PANEL_TYPES.METRIC, series.time_range_mode)) {
throw invalidModelError();
}
const reducedTimeRange = getReducedTimeRange(model, series, timeRange);
// handle multiple metrics
const metricsColumns = getMetricsColumns(series, currentIndexPattern!, seriesNum, {
reducedTimeRange,
});
if (metricsColumns === null) {
throw invalidModelError();
}
const bucketsColumns = getBucketsColumns(
model,
series,
metricsColumns,
currentIndexPattern!,
false
);
if (bucketsColumns === null) {
throw invalidModelError();
}
buckets.push(...bucketsColumns);
metrics.push(...metricsColumns);
}
let uniqueBuckets = buckets;
if (visibleSeries.length === MAX_SERIES && buckets.length) {
if (buckets.length !== MAX_BUCKETS) {
throw invalidModelError();
}
uniqueBuckets = getUniqueBuckets(buckets as ColumnsWithoutMeta[]);
if (uniqueBuckets.length !== 1) {
throw invalidModelError();
}
}
const [bucket] = uniqueBuckets;
const extendedLayer: ExtendedLayer = {
indexPatternId: indexPatternId as string,
layerId: uuid(),
columns: [...metrics, ...(bucket ? [bucket] : [])],
columnOrder: [],
};
const configuration = getConfiguration(model, extendedLayer, bucket);
if (!configuration) {
throw invalidModelError();
}
const layers = Object.values(excludeMetaFromLayers({ 0: extendedLayer }));
return {
type: 'lnsMetric',
layers,
configuration,
indexPatternIds: getIndexPatternIds(layers),
};
} catch (e) {
return null;
}
const [indexPatternId] = indexPatternIds.values();
const buckets = [];
const metrics = [];
// handle multiple layers/series
for (const series of visibleSeries) {
// not valid time shift
if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') {
return null;
}
if (!isValidMetrics(series.metrics, PANEL_TYPES.METRIC, series.time_range_mode)) {
return null;
}
const reducedTimeRange = getReducedTimeRange(model, series, timeRange);
// handle multiple metrics
const metricsColumns = getMetricsColumns(series, currentIndexPattern!, seriesNum, {
reducedTimeRange,
});
if (metricsColumns === null) {
return null;
}
const bucketsColumns = getBucketsColumns(
model,
series,
metricsColumns,
currentIndexPattern!,
false
);
if (bucketsColumns === null) {
return null;
}
buckets.push(...bucketsColumns);
metrics.push(...metricsColumns);
}
let uniqueBuckets = buckets;
if (visibleSeries.length === MAX_SERIES && buckets.length) {
if (buckets.length !== MAX_BUCKETS) {
return null;
}
uniqueBuckets = getUniqueBuckets(buckets as ColumnsWithoutMeta[]);
if (uniqueBuckets.length !== 1) {
return null;
}
}
const [bucket] = uniqueBuckets;
const extendedLayer: ExtendedLayer = {
indexPatternId: indexPatternId as string,
layerId: uuid(),
columns: [...metrics, ...(bucket ? [bucket] : [])],
columnOrder: [],
};
const configuration = getConfiguration(model, extendedLayer, bucket);
if (!configuration) {
return null;
}
const layers = Object.values(excludeMetaFromLayers({ 0: extendedLayer }));
return {
type: 'lnsMetric',
layers,
configuration,
indexPatternIds: getIndexPatternIds(layers),
};
};

View file

@ -23,7 +23,7 @@ const mockIsValidMetrics = jest.fn();
const mockGetDatasourceValue = jest
.fn()
.mockImplementation(() => Promise.resolve(stubLogstashDataView));
const mockGetDataSourceInfo = jest.fn();
const mockExtractOrGenerateDatasourceInfo = jest.fn();
const mockGetColumnState = jest.fn();
jest.mock('../../services', () => ({
@ -49,7 +49,7 @@ jest.mock('../lib/metrics', () => ({
}));
jest.mock('../lib/datasource', () => ({
getDataSourceInfo: jest.fn(() => mockGetDataSourceInfo()),
extractOrGenerateDatasourceInfo: jest.fn(() => mockExtractOrGenerateDatasourceInfo()),
}));
describe('convertToLens', () => {
@ -73,7 +73,7 @@ describe('convertToLens', () => {
beforeEach(() => {
mockIsValidMetrics.mockReturnValue(true);
mockGetDataSourceInfo.mockReturnValue({
mockExtractOrGenerateDatasourceInfo.mockReturnValue({
indexPatternId: 'test-index-pattern',
timeField: 'timeField',
indexPattern: { id: 'test-index-pattern' },

View file

@ -12,7 +12,7 @@ import { getIndexPatternIds, Layer } from '@kbn/visualizations-plugin/common/con
import { PANEL_TYPES } from '../../../common/enums';
import { getDataViewsStart } from '../../services';
import { getColumnState } from '../lib/configurations/table';
import { getDataSourceInfo } from '../lib/datasource';
import { extractOrGenerateDatasourceInfo } from '../lib/datasource';
import { getMetricsColumns, getBucketsColumns } from '../lib/series';
import { getReducedTimeRange, isValidMetrics } from '../lib/metrics';
import { ConvertTsvbToLensVisualization } from '../types';
@ -28,156 +28,163 @@ const excludeMetaFromLayers = (layers: Record<string, ExtendedLayer>): Record<st
return newLayers;
};
const invalidModelError = () => new Error('Invalid model');
export const convertToLens: ConvertTsvbToLensVisualization = async (
{ params: model, uiState },
timeRange
) => {
const columnStates = [];
const dataViews = getDataViewsStart();
const seriesNum = model.series.filter((series) => !series.hidden).length;
const sortConfig = uiState.get('table')?.sort ?? {};
const datasourceInfo = await getDataSourceInfo(
model.index_pattern,
model.time_field,
false,
undefined,
undefined,
dataViews
);
try {
const seriesNum = model.series.filter((series) => !series.hidden).length;
const sortConfig = uiState.get('table')?.sort ?? {};
if (!datasourceInfo) {
return null;
}
const datasourceInfo = await extractOrGenerateDatasourceInfo(
model.index_pattern,
model.time_field,
false,
undefined,
undefined,
dataViews
);
const { indexPatternId, indexPattern } = datasourceInfo;
const commonBucketsColumns = getBucketsColumns(
undefined,
{
split_mode: 'terms',
terms_field: model.pivot_id,
terms_size: model.pivot_rows ? model.pivot_rows.toString() : undefined,
},
[],
indexPattern!,
false,
model.pivot_label,
false
);
if (!commonBucketsColumns) {
return null;
}
const sortConfiguration = {
columnId: commonBucketsColumns[0].columnId,
direction: sortConfig.order,
};
columnStates.push(getColumnState(commonBucketsColumns[0].columnId));
let bucketsColumns: Column[] | null = [];
if (
!model.series.every(
(s) =>
((!s.aggregate_by && !model.series[0].aggregate_by) ||
s.aggregate_by === model.series[0].aggregate_by) &&
((!s.aggregate_function && !model.series[0].aggregate_function) ||
s.aggregate_function === model.series[0].aggregate_function)
)
) {
return null;
}
if (model.series[0].aggregate_by) {
if (
!model.series[0].aggregate_function ||
!['sum', 'mean', 'min', 'max'].includes(model.series[0].aggregate_function)
) {
return null;
if (!datasourceInfo) {
throw invalidModelError();
}
bucketsColumns = getBucketsColumns(
const { indexPatternId, indexPattern } = datasourceInfo;
const commonBucketsColumns = getBucketsColumns(
undefined,
{
split_mode: 'terms',
terms_field: model.series[0].aggregate_by,
terms_field: model.pivot_id,
terms_size: model.pivot_rows ? model.pivot_rows.toString() : undefined,
},
[],
indexPattern!,
false,
model.pivot_label,
false
);
if (bucketsColumns === null) {
return null;
if (!commonBucketsColumns) {
throw invalidModelError();
}
columnStates.push(
getColumnState(
bucketsColumns[0].columnId,
model.series[0].aggregate_function === 'mean' ? 'avg' : model.series[0].aggregate_function
const sortConfiguration = {
columnId: commonBucketsColumns[0].columnId,
direction: sortConfig.order,
};
columnStates.push(getColumnState(commonBucketsColumns[0].columnId));
let bucketsColumns: Column[] | null = [];
if (
!model.series.every(
(s) =>
((!s.aggregate_by && !model.series[0].aggregate_by) ||
s.aggregate_by === model.series[0].aggregate_by) &&
((!s.aggregate_function && !model.series[0].aggregate_function) ||
s.aggregate_function === model.series[0].aggregate_function)
)
);
}
const metrics = [];
// handle multiple layers/series
for (const [_, series] of model.series.entries()) {
if (series.hidden) {
continue;
) {
throw invalidModelError();
}
// not valid time shift
if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') {
return null;
if (model.series[0].aggregate_by) {
if (
!model.series[0].aggregate_function ||
!['sum', 'mean', 'min', 'max'].includes(model.series[0].aggregate_function)
) {
throw invalidModelError();
}
bucketsColumns = getBucketsColumns(
undefined,
{
split_mode: 'terms',
terms_field: model.series[0].aggregate_by,
},
[],
indexPattern!,
false
);
if (bucketsColumns === null) {
throw invalidModelError();
}
columnStates.push(
getColumnState(
bucketsColumns[0].columnId,
model.series[0].aggregate_function === 'mean' ? 'avg' : model.series[0].aggregate_function
)
);
}
if (!isValidMetrics(series.metrics, PANEL_TYPES.TABLE, series.time_range_mode)) {
return null;
const metrics = [];
// handle multiple layers/series
for (const [_, series] of model.series.entries()) {
if (series.hidden) {
continue;
}
// not valid time shift
if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') {
throw invalidModelError();
}
if (!isValidMetrics(series.metrics, PANEL_TYPES.TABLE, series.time_range_mode)) {
throw invalidModelError();
}
const reducedTimeRange = getReducedTimeRange(model, series, timeRange);
// handle multiple metrics
const metricsColumns = getMetricsColumns(series, indexPattern!, seriesNum, {
reducedTimeRange,
});
if (!metricsColumns) {
throw invalidModelError();
}
columnStates.push(getColumnState(metricsColumns[0].columnId, undefined, series));
if (sortConfig.column === series.id) {
sortConfiguration.columnId = metricsColumns[0].columnId;
}
metrics.push(...metricsColumns);
}
const reducedTimeRange = getReducedTimeRange(model, series, timeRange);
// handle multiple metrics
const metricsColumns = getMetricsColumns(series, indexPattern!, seriesNum, {
reducedTimeRange,
});
if (!metricsColumns) {
return null;
if (!metrics.length || metrics.every((metric) => metric.operationType === 'static_value')) {
throw invalidModelError();
}
columnStates.push(getColumnState(metricsColumns[0].columnId, undefined, series));
const extendedLayer: ExtendedLayer = {
indexPatternId: indexPatternId as string,
layerId: uuid(),
columns: [...metrics, ...commonBucketsColumns, ...bucketsColumns],
columnOrder: [],
};
if (sortConfig.column === series.id) {
sortConfiguration.columnId = metricsColumns[0].columnId;
}
const layers = Object.values(excludeMetaFromLayers({ 0: extendedLayer }));
metrics.push(...metricsColumns);
}
if (!metrics.length || metrics.every((metric) => metric.operationType === 'static_value')) {
return {
type: 'lnsDatatable',
layers,
configuration: {
columns: columnStates,
layerId: extendedLayer.layerId,
layerType: 'data',
sorting: sortConfiguration,
},
indexPatternIds: getIndexPatternIds(layers),
};
} catch (e) {
return null;
}
const extendedLayer: ExtendedLayer = {
indexPatternId: indexPatternId as string,
layerId: uuid(),
columns: [...metrics, ...commonBucketsColumns, ...bucketsColumns],
columnOrder: [],
};
const layers = Object.values(excludeMetaFromLayers({ 0: extendedLayer }));
return {
type: 'lnsDatatable',
layers,
configuration: {
columns: columnStates,
layerId: extendedLayer.layerId,
layerType: 'data',
sorting: sortConfiguration,
},
indexPatternIds: getIndexPatternIds(layers),
};
};

View file

@ -21,7 +21,7 @@ const mockIsValidMetrics = jest.fn();
const mockGetDatasourceValue = jest
.fn()
.mockImplementation(() => Promise.resolve(stubLogstashDataView));
const mockGetDataSourceInfo = jest.fn();
const mockExtractOrGenerateDatasourceInfo = jest.fn();
jest.mock('../../services', () => ({
getDataViewsStart: jest.fn(() => mockGetDatasourceValue),
@ -47,7 +47,7 @@ jest.mock('../lib/metrics', () => ({
}));
jest.mock('../lib/datasource', () => ({
getDataSourceInfo: jest.fn(() => mockGetDataSourceInfo()),
extractOrGenerateDatasourceInfo: jest.fn(() => mockExtractOrGenerateDatasourceInfo()),
}));
describe('convertToLens', () => {
@ -68,7 +68,7 @@ describe('convertToLens', () => {
beforeEach(() => {
mockIsValidMetrics.mockReturnValue(true);
mockGetDataSourceInfo.mockReturnValue({
mockExtractOrGenerateDatasourceInfo.mockReturnValue({
indexPatternId: 'test-index-pattern',
timeField: 'timeField',
indexPattern: { id: 'test-index-pattern' },
@ -91,10 +91,10 @@ describe('convertToLens', () => {
});
test('should return null for empty time field', async () => {
mockGetDataSourceInfo.mockReturnValue({ timeField: null });
mockExtractOrGenerateDatasourceInfo.mockReturnValue({ timeField: null });
const result = await convertToLens(vis);
expect(result).toBeNull();
expect(mockGetDataSourceInfo).toBeCalledTimes(1);
expect(mockExtractOrGenerateDatasourceInfo).toBeCalledTimes(1);
});
test('should return null for invalid date histogram', async () => {
@ -139,6 +139,14 @@ describe('convertToLens', () => {
expect(mockGetConfigurationForTimeseries).toBeCalledTimes(1);
});
test('should drop adhoc dataviews if action is required', async () => {
const result = await convertToLens(vis, undefined, true);
expect(result).toBeDefined();
expect(result?.type).toBe('lnsXY');
expect(mockGetBucketsColumns).toBeCalledTimes(model.series.length);
expect(mockGetConfigurationForTimeseries).toBeCalledTimes(1);
});
test('should skip hidden series', async () => {
const result = await convertToLens({
params: createPanel({

View file

@ -16,7 +16,7 @@ import uuid from 'uuid';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { PANEL_TYPES } from '../../../common/enums';
import { getDataViewsStart } from '../../services';
import { getDataSourceInfo } from '../lib/datasource';
import { extractOrGenerateDatasourceInfo } from '../lib/datasource';
import { getMetricsColumns, getBucketsColumns } from '../lib/series';
import {
getConfigurationForTimeseries as getConfiguration,
@ -40,100 +40,107 @@ const excludeMetaFromLayers = (layers: Record<string, ExtendedLayer>): Record<st
return newLayers;
};
const invalidModelError = () => new Error('Invalid model');
export const convertToLens: ConvertTsvbToLensVisualization = async ({ params: model }) => {
const dataViews: DataViewsPublicPluginStart = getDataViewsStart();
const extendedLayers: Record<number, ExtendedLayer> = {};
const seriesNum = model.series.filter((series) => !series.hidden).length;
try {
const extendedLayers: Record<number, ExtendedLayer> = {};
const seriesNum = model.series.filter((series) => !series.hidden).length;
// handle multiple layers/series
for (const [layerIdx, series] of model.series.entries()) {
if (series.hidden) {
continue;
// handle multiple layers/series
for (const [layerIdx, series] of model.series.entries()) {
if (series.hidden) {
continue;
}
// not valid time shift
if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') {
throw invalidModelError();
}
if (!isValidMetrics(series.metrics, PANEL_TYPES.TIMESERIES)) {
throw invalidModelError();
}
const datasourceInfo = await extractOrGenerateDatasourceInfo(
model.index_pattern,
model.time_field,
Boolean(series.override_index_pattern),
series.series_index_pattern,
series.series_time_field,
dataViews
);
if (!datasourceInfo) {
throw invalidModelError();
}
const { indexPatternId, indexPattern, timeField } = datasourceInfo;
if (!timeField) {
throw invalidModelError();
}
const dateHistogramColumn = convertToDateHistogramColumn(model, series, indexPattern!, {
fieldName: timeField,
isSplit: false,
});
if (dateHistogramColumn === null) {
throw invalidModelError();
}
// handle multiple metrics
const metricsColumns = getMetricsColumns(series, indexPattern!, seriesNum, {
isStaticValueColumnSupported: true,
});
if (metricsColumns === null) {
throw invalidModelError();
}
const bucketsColumns = getBucketsColumns(model, series, metricsColumns, indexPattern!, true);
if (bucketsColumns === null) {
throw invalidModelError();
}
const isReferenceLine =
metricsColumns.length === 1 && metricsColumns[0].operationType === 'static_value';
// only static value without split is supported
if (isReferenceLine && bucketsColumns.length) {
throw invalidModelError();
}
const layerId = uuid();
extendedLayers[layerIdx] = {
indexPatternId,
layerId,
columns: isReferenceLine
? [...metricsColumns]
: [...metricsColumns, dateHistogramColumn, ...bucketsColumns],
columnOrder: [],
};
}
// not valid time shift
if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') {
return null;
const configLayers = await getLayers(extendedLayers, model, dataViews);
if (configLayers === null) {
throw invalidModelError();
}
if (!isValidMetrics(series.metrics, PANEL_TYPES.TIMESERIES)) {
return null;
}
const configuration = getConfiguration(model, configLayers);
const layers = Object.values(excludeMetaFromLayers(extendedLayers));
const annotationIndexPatterns = configuration.layers.reduce<string[]>((acc, layer) => {
if (isAnnotationsLayer(layer)) {
return [...acc, layer.indexPatternId];
}
return acc;
}, []);
const datasourceInfo = await getDataSourceInfo(
model.index_pattern,
model.time_field,
Boolean(series.override_index_pattern),
series.series_index_pattern,
series.series_time_field,
dataViews
);
if (!datasourceInfo) {
return null;
}
const { indexPatternId, indexPattern, timeField } = datasourceInfo;
if (!timeField) {
return null;
}
const dateHistogramColumn = convertToDateHistogramColumn(model, series, indexPattern!, {
fieldName: timeField,
isSplit: false,
});
if (dateHistogramColumn === null) {
return null;
}
// handle multiple metrics
const metricsColumns = getMetricsColumns(series, indexPattern!, seriesNum, {
isStaticValueColumnSupported: true,
});
if (metricsColumns === null) {
return null;
}
const bucketsColumns = getBucketsColumns(model, series, metricsColumns, indexPattern!, true);
if (bucketsColumns === null) {
return null;
}
const isReferenceLine =
metricsColumns.length === 1 && metricsColumns[0].operationType === 'static_value';
// only static value without split is supported
if (isReferenceLine && bucketsColumns.length) {
return null;
}
const layerId = uuid();
extendedLayers[layerIdx] = {
indexPatternId,
layerId,
columns: isReferenceLine
? [...metricsColumns]
: [...metricsColumns, dateHistogramColumn, ...bucketsColumns],
columnOrder: [],
return {
type: 'lnsXY',
layers,
configuration,
indexPatternIds: [...getIndexPatternIds(layers), ...annotationIndexPatterns],
};
}
const configLayers = await getLayers(extendedLayers, model, dataViews);
if (configLayers === null) {
} catch (e) {
return null;
}
const configuration = getConfiguration(model, configLayers);
const layers = Object.values(excludeMetaFromLayers(extendedLayers));
const annotationIndexPatterns = configuration.layers.reduce<string[]>((acc, layer) => {
if (isAnnotationsLayer(layer)) {
return [...acc, layer.indexPatternId];
}
return acc;
}, []);
return {
type: 'lnsXY',
layers,
configuration,
indexPatternIds: [...getIndexPatternIds(layers), ...annotationIndexPatterns],
};
};

View file

@ -20,7 +20,7 @@ const mockIsValidMetrics = jest.fn();
const mockGetDatasourceValue = jest
.fn()
.mockImplementation(() => Promise.resolve(stubLogstashDataView));
const mockGetDataSourceInfo = jest.fn();
const mockExtractOrGenerateDatasourceInfo = jest.fn();
jest.mock('../../services', () => ({
getDataViewsStart: jest.fn(() => mockGetDatasourceValue),
@ -46,7 +46,7 @@ jest.mock('../lib/metrics', () => ({
}));
jest.mock('../lib/datasource', () => ({
getDataSourceInfo: jest.fn(() => mockGetDataSourceInfo()),
extractOrGenerateDatasourceInfo: jest.fn(() => mockExtractOrGenerateDatasourceInfo()),
}));
describe('convertToLens', () => {
@ -67,7 +67,7 @@ describe('convertToLens', () => {
beforeEach(() => {
mockIsValidMetrics.mockReturnValue(true);
mockGetDataSourceInfo.mockReturnValue({
mockExtractOrGenerateDatasourceInfo.mockReturnValue({
indexPatternId: 'test-index-pattern',
timeField: 'timeField',
indexPattern: { id: 'test-index-pattern' },
@ -110,6 +110,14 @@ describe('convertToLens', () => {
expect(mockGetConfigurationForTopN).toBeCalledTimes(1);
});
test('should drop adhoc dataviews if action is required', async () => {
const result = await convertToLens(vis, undefined, true);
expect(result).toBeDefined();
expect(result?.type).toBe('lnsXY');
expect(mockGetBucketsColumns).toBeCalledTimes(model.series.length);
expect(mockGetConfigurationForTopN).toBeCalledTimes(1);
});
test('should skip hidden series', async () => {
const result = await convertToLens({
params: createPanel({

View file

@ -11,7 +11,7 @@ import { parseTimeShift } from '@kbn/data-plugin/common';
import { getIndexPatternIds, Layer } from '@kbn/visualizations-plugin/common/convert_to_lens';
import { PANEL_TYPES } from '../../../common/enums';
import { getDataViewsStart } from '../../services';
import { getDataSourceInfo } from '../lib/datasource';
import { extractOrGenerateDatasourceInfo } from '../lib/datasource';
import { getMetricsColumns, getBucketsColumns } from '../lib/series';
import { getConfigurationForTopN as getConfiguration, getLayers } from '../lib/configurations/xy';
import { getReducedTimeRange, isValidMetrics } from '../lib/metrics';
@ -28,77 +28,84 @@ const excludeMetaFromLayers = (layers: Record<string, ExtendedLayer>): Record<st
return newLayers;
};
const invalidModelError = () => new Error('Invalid model');
export const convertToLens: ConvertTsvbToLensVisualization = async (
{ params: model },
timeRange
) => {
const dataViews = getDataViewsStart();
const extendedLayers: Record<number, ExtendedLayer> = {};
const seriesNum = model.series.filter((series) => !series.hidden).length;
try {
const extendedLayers: Record<number, ExtendedLayer> = {};
const seriesNum = model.series.filter((series) => !series.hidden).length;
// handle multiple layers/series
for (const [layerIdx, series] of model.series.entries()) {
if (series.hidden) {
continue;
// handle multiple layers/series
for (const [layerIdx, series] of model.series.entries()) {
if (series.hidden) {
continue;
}
// not valid time shift
if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') {
throw invalidModelError();
}
if (!isValidMetrics(series.metrics, PANEL_TYPES.TOP_N, series.time_range_mode)) {
throw invalidModelError();
}
const datasourceInfo = await extractOrGenerateDatasourceInfo(
model.index_pattern,
model.time_field,
Boolean(series.override_index_pattern),
series.series_index_pattern,
series.series_time_field,
dataViews
);
if (!datasourceInfo) {
throw invalidModelError();
}
const { indexPatternId, indexPattern } = datasourceInfo;
const reducedTimeRange = getReducedTimeRange(model, series, timeRange);
// handle multiple metrics
const metricsColumns = getMetricsColumns(series, indexPattern!, seriesNum, {
reducedTimeRange,
});
if (!metricsColumns) {
throw invalidModelError();
}
const bucketsColumns = getBucketsColumns(model, series, metricsColumns, indexPattern!, false);
if (bucketsColumns === null) {
throw invalidModelError();
}
const layerId = uuid();
extendedLayers[layerIdx] = {
indexPatternId,
layerId,
columns: [...metricsColumns, ...bucketsColumns],
columnOrder: [],
};
}
// not valid time shift
if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') {
return null;
const configLayers = await getLayers(extendedLayers, model, dataViews, true);
if (configLayers === null) {
throw invalidModelError();
}
if (!isValidMetrics(series.metrics, PANEL_TYPES.TOP_N, series.time_range_mode)) {
return null;
}
const layers = Object.values(excludeMetaFromLayers(extendedLayers));
const datasourceInfo = await getDataSourceInfo(
model.index_pattern,
model.time_field,
Boolean(series.override_index_pattern),
series.series_index_pattern,
series.series_time_field,
dataViews
);
if (!datasourceInfo) {
return null;
}
const { indexPatternId, indexPattern } = datasourceInfo;
const reducedTimeRange = getReducedTimeRange(model, series, timeRange);
// handle multiple metrics
const metricsColumns = getMetricsColumns(series, indexPattern!, seriesNum, {
reducedTimeRange,
});
if (!metricsColumns) {
return null;
}
const bucketsColumns = getBucketsColumns(model, series, metricsColumns, indexPattern!, false);
if (bucketsColumns === null) {
return null;
}
const layerId = uuid();
extendedLayers[layerIdx] = {
indexPatternId,
layerId,
columns: [...metricsColumns, ...bucketsColumns],
columnOrder: [],
return {
type: 'lnsXY',
layers,
configuration: getConfiguration(model, configLayers),
indexPatternIds: getIndexPatternIds(layers),
};
}
const configLayers = await getLayers(extendedLayers, model, dataViews, true);
if (configLayers === null) {
} catch (e) {
return null;
}
const layers = Object.values(excludeMetaFromLayers(extendedLayers));
return {
type: 'lnsXY',
layers,
configuration: getConfiguration(model, configLayers),
indexPatternIds: getIndexPatternIds(layers),
};
};

View file

@ -18,7 +18,8 @@ import type { Panel } from '../../common/types';
export type ConvertTsvbToLensVisualization = (
vis: Vis<Panel>,
timeRange?: TimeRange
timeRange?: TimeRange,
clearAdHocDataViews?: boolean
) => Promise<NavigateToLensContext<
XYConfiguration | MetricVisConfiguration | TableVisConfiguration
> | null>;

View file

@ -10,6 +10,7 @@ import type { DataViewsContract, DataView, DataViewSpec } from '@kbn/data-views-
import { keyBy } from 'lodash';
import { IndexPattern, IndexPatternField, IndexPatternMap, IndexPatternRef } from '../types';
import { documentField } from '../datasources/form_based/document_field';
import { sortDataViewRefs } from '../utils';
type ErrorHandler = (err: Error) => void;
type MinimalDataViewsContract = Pick<DataViewsContract, 'get' | 'getIdsWithTitle' | 'create'>;
@ -111,34 +112,10 @@ export function convertDataViewIntoLensIndexPattern(
}
export async function loadIndexPatternRefs(
dataViews: MinimalDataViewsContract,
adHocDataViews?: Record<string, DataViewSpec>,
contextDataViewSpec?: DataViewSpec
dataViews: MinimalDataViewsContract
): Promise<IndexPatternRef[]> {
const indexPatterns = await dataViews.getIdsWithTitle();
const missedIndexPatterns = Object.values(adHocDataViews || {});
// add data view from context
if (contextDataViewSpec) {
const existingDataView = indexPatterns.find(
(indexPattern) => indexPattern.id === contextDataViewSpec.id
);
if (!existingDataView) {
missedIndexPatterns.push(contextDataViewSpec);
}
}
return indexPatterns
.concat(
missedIndexPatterns.map((dataViewSpec) => ({
id: dataViewSpec.id!,
name: dataViewSpec.name,
title: dataViewSpec.title!,
}))
)
.sort((a, b) => {
return a.title.localeCompare(b.title);
});
const indexPatternsRefs = await dataViews.getIdsWithTitle();
return sortDataViewRefs(indexPatternsRefs);
}
/**

View file

@ -109,9 +109,7 @@ function getUsedIndexPatterns({
const indexPatternIds = [];
if (initialContext) {
if ('isVisualizeAction' in initialContext) {
for (const { indexPatternId } of initialContext.layers) {
indexPatternIds.push(indexPatternId);
}
indexPatternIds.push(...initialContext.indexPatternIds);
} else {
indexPatternIds.push(initialContext.dataViewSpec.id!);
}

View file

@ -29,7 +29,7 @@ import {
import { buildExpression } from './expression_helpers';
import { showMemoizedErrorNotification } from '../../lens_ui_errors';
import { Document } from '../../persistence/saved_object_store';
import { getActiveDatasourceIdFromDoc } from '../../utils';
import { getActiveDatasourceIdFromDoc, sortDataViewRefs } from '../../utils';
import type { ErrorMessage } from '../types';
import {
getMissingCurrentDatasource,
@ -81,6 +81,20 @@ const getLastUsedIndexPatternId = (
return indexPattern && indexPatternRefs.find((i) => i.id === indexPattern)?.id;
};
const getRefsForAdHocDataViewsFromContext = (
indexPatternRefs: IndexPatternRef[],
usedIndexPatternsIds: string[],
indexPatterns: Record<string, IndexPattern>
) => {
const indexPatternIds = indexPatternRefs.map(({ id }) => id);
const adHocDataViewsIds = usedIndexPatternsIds.filter((id) => !indexPatternIds.includes(id));
const adHocDataViewsList = Object.values(indexPatterns).filter(({ id }) =>
adHocDataViewsIds.includes(id)
);
return adHocDataViewsList.map(({ id, title, name }) => ({ id, title, name }));
};
export async function initializeDataViews(
{
dataViews,
@ -110,10 +124,10 @@ export async function initializeDataViews(
})
);
const { isFullEditor } = options ?? {};
const contextDataViewSpec = (initialContext as VisualizeFieldContext)?.dataViewSpec;
// make it explicit or TS will infer never[] and break few lines down
const indexPatternRefs: IndexPatternRef[] = await (isFullEditor
? loadIndexPatternRefs(dataViews, adHocDataViews, contextDataViewSpec)
? loadIndexPatternRefs(dataViews)
: []);
// if no state is available, use the fallbackId
@ -127,7 +141,7 @@ export async function initializeDataViews(
const adHocDataviewsIds: string[] = Object.keys(adHocDataViews || {});
const usedIndexPatterns = getIndexPatterns(
const usedIndexPatternsIds = getIndexPatterns(
references,
initialContext,
initialId,
@ -137,17 +151,25 @@ export async function initializeDataViews(
// load them
const availableIndexPatterns = new Set(indexPatternRefs.map(({ id }: IndexPatternRef) => id));
const notUsedPatterns: string[] = difference([...availableIndexPatterns], usedIndexPatterns);
const notUsedPatterns: string[] = difference([...availableIndexPatterns], usedIndexPatternsIds);
const indexPatterns = await loadIndexPatterns({
dataViews,
patterns: usedIndexPatterns,
patterns: usedIndexPatternsIds,
notUsedPatterns,
cache: {},
adHocDataViews,
});
return { indexPatternRefs, indexPatterns };
const adHocDataViewsRefs = getRefsForAdHocDataViewsFromContext(
indexPatternRefs,
usedIndexPatternsIds,
indexPatterns
);
return {
indexPatternRefs: sortDataViewRefs([...indexPatternRefs, ...adHocDataViewsRefs]),
indexPatterns,
};
}
/**

View file

@ -284,6 +284,11 @@ export const isOperationFromTheSameGroup = (op1?: DraggingIdentifier, op2?: Drag
);
};
export const sortDataViewRefs = (dataViewRefs: IndexPatternRef[]) =>
dataViewRefs.sort((a, b) => {
return a.title.localeCompare(b.title);
});
export const getSearchWarningMessages = (
adapter: RequestAdapter,
datasource: Datasource,