[Lens] Tsvb to lens for annotations (#140718)

* fix typo

* [TSVB to Lens] tsvb part

* Lens part

* tests

* add ignoreGlobalFilters

* import corrected

* adding star filled

* widen the limits, spread the horizons

Co-authored-by: Joe Reuter <johannes.reuter@elastic.co>
This commit is contained in:
Marta Bondyra 2022-09-20 18:46:29 +02:00 committed by GitHub
parent 19097ddae9
commit 6fda415278
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 430 additions and 19 deletions

View file

@ -32,7 +32,7 @@ pageLoadAssetSize:
embeddableEnhanced: 22107
enterpriseSearch: 35741
esUiShared: 326654
eventAnnotation: 19500
eventAnnotation: 20500
expressionError: 22127
expressionGauge: 25000
expressionHeatmap: 27505

View file

@ -123,6 +123,7 @@ export const AvailableReferenceLineIcons = {
MAP_MARKER: 'mapMarker',
PIN_FILLED: 'pinFilled',
STAR_EMPTY: 'starEmpty',
STAR_FILLED: 'starFilled',
TAG: 'tag',
TRIANGLE: 'triangle',
} as const;

View file

@ -94,6 +94,12 @@ export const iconSet = [
value: AvailableReferenceLineIcons.STAR_EMPTY,
label: i18n.translate('expressionXY.xyChart.iconSelect.starLabel', { defaultMessage: 'Star' }),
},
{
value: AvailableReferenceLineIcons.STAR_FILLED,
label: i18n.translate('expressionXY.xyChart.iconSelect.starFilledLabel', {
defaultMessage: 'Star filled',
}),
},
{
value: AvailableReferenceLineIcons.TAG,
label: i18n.translate('expressionXY.xyChart.iconSelect.tagIconLabel', {

View file

@ -19,6 +19,7 @@ export const AvailableAnnotationIcons = {
MAP_MARKER: 'mapMarker',
PIN_FILLED: 'pinFilled',
STAR_EMPTY: 'starEmpty',
STAR_FILLED: 'starFilled',
TAG: 'tag',
TRIANGLE: 'triangle',
} as const;

View file

@ -33,7 +33,7 @@ const getConvertFnByType = (type: PANEL_TYPES) => {
*/
export const convertTSVBtoLensConfiguration = async (model: Panel, timeRange?: TimeRange) => {
// Disables the option for not supported charts, for the string mode and for series with annotations
if (!model.use_kibana_indexes || (model.annotations && model.annotations.length > 0)) {
if (!model.use_kibana_indexes) {
return null;
}
// Disables if model is invalid

View file

@ -17,6 +17,12 @@ import {
} from '../../convert';
import { getLayers } from './layers';
import { createPanel, createSeries } from '../../__mocks__';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
jest.mock('uuid', () => ({
v4: () => 'test-id',
}));
describe('getLayers', () => {
const dataSourceLayers: Record<number, Layer> = [
@ -200,6 +206,84 @@ describe('getLayers', () => {
const panelWithPercentileRankMetric = createPanel({
series: [createSeries({ metrics: percentileRankMetrics })],
});
const panelWithSingleAnnotation = createPanel({
annotations: [
{
fields: 'geo.src,host',
template: 'Security Error from {{geo.src}} on {{host}}',
query_string: {
query: 'tags:error AND tags:security',
language: 'lucene',
},
id: 'ann1',
color: 'rgba(211,49,21,0.7)',
time_field: 'timestamp',
icon: 'fa-asterisk',
ignore_global_filters: 1,
ignore_panel_filters: 1,
hidden: true,
index_pattern: {
id: 'test',
},
},
],
series: [createSeries({ metrics: staticValueMetric })],
});
const panelWithMultiAnnotations = createPanel({
annotations: [
{
fields: 'geo.src,host',
template: 'Security Error from {{geo.src}} on {{host}}',
query_string: {
query: 'tags:error AND tags:security',
language: 'lucene',
},
id: 'ann1',
color: 'rgba(211,49,21,0.7)',
time_field: 'timestamp',
icon: 'fa-asterisk',
ignore_global_filters: 1,
ignore_panel_filters: 1,
hidden: true,
index_pattern: {
id: 'test',
},
},
{
query_string: {
query: 'tags: error AND tags: security',
language: 'kql',
},
id: 'ann2',
color: 'blue',
time_field: 'timestamp',
icon: 'error-icon',
ignore_global_filters: 0, // todo test ignore when PR is r
ignore_panel_filters: 0,
index_pattern: {
id: 'test',
},
},
{
fields: 'category.keyword,price',
template: 'Will be ignored',
query_string: {
query: 'category.keyword:*',
language: 'kql',
},
id: 'ann3',
color: 'red',
time_field: 'order_date',
icon: undefined,
ignore_global_filters: 1,
ignore_panel_filters: 1,
index_pattern: {
id: 'test2',
},
},
],
series: [createSeries({ metrics: staticValueMetric })],
});
test.each<[string, [Record<number, Layer>, Panel], Array<Partial<XYLayerConfig>>]>([
[
@ -282,7 +366,159 @@ describe('getLayers', () => {
},
],
],
])('should return %s', (_, input, expected) => {
expect(getLayers(...input)).toEqual(expected.map(expect.objectContaining));
[
'annotation layer gets correct params and converts color, extraFields and icons',
[dataSourceLayersWithStatic, panelWithSingleAnnotation],
[
{
layerType: 'referenceLine',
accessors: ['column-id-1'],
layerId: 'test-layer-1',
yConfig: [
{
forAccessor: 'column-id-1',
axisMode: 'right',
color: '#68BC00',
fill: 'below',
},
],
},
{
layerId: 'test-id',
layerType: 'annotations',
ignoreGlobalFilters: true,
annotations: [
{
color: '#D33115',
extraFields: ['geo.src'],
filter: {
language: 'lucene',
query: 'tags:error AND tags:security',
type: 'kibana_query',
},
icon: 'asterisk',
id: 'ann1',
isHidden: true,
key: {
type: 'point_in_time',
},
label: 'Event',
timeField: 'timestamp',
type: 'query',
},
],
indexPatternId: 'test',
},
],
],
[
'multiple annotations with different data views create separate layers',
[dataSourceLayersWithStatic, panelWithMultiAnnotations],
[
{
layerType: 'referenceLine',
accessors: ['column-id-1'],
layerId: 'test-layer-1',
yConfig: [
{
forAccessor: 'column-id-1',
axisMode: 'right',
color: '#68BC00',
fill: 'below',
},
],
},
{
layerId: 'test-id',
layerType: 'annotations',
ignoreGlobalFilters: true,
annotations: [
{
color: '#D33115',
extraFields: ['geo.src'],
filter: {
language: 'lucene',
query: 'tags:error AND tags:security',
type: 'kibana_query',
},
icon: 'asterisk',
id: 'ann1',
isHidden: true,
key: {
type: 'point_in_time',
},
label: 'Event',
timeField: 'timestamp',
type: 'query',
},
{
color: '#0000FF',
filter: {
language: 'kql',
query: 'tags: error AND tags: security',
type: 'kibana_query',
},
icon: 'triangle',
id: 'ann2',
key: {
type: 'point_in_time',
},
label: 'Event',
timeField: 'timestamp',
type: 'query',
},
],
indexPatternId: 'test',
},
{
layerId: 'test-id',
layerType: 'annotations',
ignoreGlobalFilters: true,
annotations: [
{
color: '#FF0000',
extraFields: ['category.keyword', 'price'],
filter: {
language: 'kql',
query: 'category.keyword:*',
type: 'kibana_query',
},
icon: 'triangle',
id: 'ann3',
key: {
type: 'point_in_time',
},
label: 'Event',
timeField: 'order_date',
type: 'query',
},
],
indexPatternId: 'test2',
},
],
],
])('should return %s', async (_, input, expected) => {
const layers = await getLayers(...input, indexPatternsService as DataViewsPublicPluginStart);
expect(layers).toEqual(expected.map(expect.objectContaining));
});
});
const mockedIndices = [
{
id: 'test',
title: 'test',
getFieldByName: (name: string) => ({ aggregatable: name !== 'host' }),
},
] as unknown as DataView[];
const indexPatternsService = {
getDefault: jest.fn(() => Promise.resolve({ id: 'default', title: 'index' })),
get: jest.fn(() => Promise.resolve(mockedIndices[0])),
find: jest.fn((search: string, size: number) => {
if (size !== 1) {
// shouldn't request more than one data view since there is a significant performance penalty
throw new Error('trying to fetch too many data views');
}
return Promise.resolve(mockedIndices || []);
}),
} as unknown as DataViewsPublicPluginStart;

View file

@ -7,13 +7,23 @@
*/
import {
EventAnnotationConfig,
FillTypes,
XYAnnotationsLayerConfig,
XYLayerConfig,
YAxisMode,
} from '@kbn/visualizations-plugin/common/convert_to_lens';
import { PaletteOutput } from '@kbn/coloring';
import { v4 } from 'uuid';
import { transparentize } from '@elastic/eui';
import Color from 'color';
import { euiLightVars } from '@kbn/ui-theme';
import { groupBy } from 'lodash';
import { DataViewsPublicPluginStart, DataView } from '@kbn/data-plugin/public/data_views';
import { fetchIndexPattern } from '../../../../../common/index_patterns_utils';
import { ICON_TYPES_MAP } from '../../../../application/visualizations/constants';
import { SUPPORTED_METRICS } from '../../metrics';
import type { Metric, Panel } from '../../../../../common/types';
import type { Annotation, Metric, Panel } from '../../../../../common/types';
import { getSeriesAgg } from '../../series';
import {
isPercentileRanksColumnWithMeta,
@ -51,11 +61,16 @@ function getColor(
return seriesColor;
}
export const getLayers = (
function nonNullable<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}
export const getLayers = async (
dataSourceLayers: Record<number, Layer>,
model: Panel
): XYLayerConfig[] => {
return Object.keys(dataSourceLayers).map((key) => {
model: Panel,
dataViews: DataViewsPublicPluginStart
): Promise<XYLayerConfig[]> => {
const nonAnnotationsLayers: XYLayerConfig[] = Object.keys(dataSourceLayers).map((key) => {
const series = model.series[parseInt(key, 10)];
const { metrics, seriesAgg } = getSeriesAgg(series.metrics);
const dataSourceLayer = dataSourceLayers[parseInt(key, 10)];
@ -112,4 +127,77 @@ export const getLayers = (
};
}
});
if (!model.annotations || !model.annotations.length) {
return nonAnnotationsLayers;
}
const annotationsByIndexPattern = groupBy(
model.annotations,
(a) => typeof a.index_pattern === 'object' && 'id' in a.index_pattern && a.index_pattern.id
);
const annotationsLayers: Array<XYAnnotationsLayerConfig | undefined> = await Promise.all(
Object.entries(annotationsByIndexPattern).map(async ([indexPatternId, annotations]) => {
const convertedAnnotations: EventAnnotationConfig[] = [];
const { indexPattern } = (await fetchIndexPattern({ id: indexPatternId }, dataViews)) || {};
if (indexPattern) {
annotations.forEach((a: Annotation) => {
const lensAnnotation = convertAnnotation(a, indexPattern);
if (lensAnnotation) {
convertedAnnotations.push(lensAnnotation);
}
});
return {
layerId: v4(),
layerType: 'annotations',
ignoreGlobalFilters: true,
annotations: convertedAnnotations,
indexPatternId,
};
}
})
);
return nonAnnotationsLayers.concat(...annotationsLayers.filter(nonNullable));
};
const convertAnnotation = (
annotation: Annotation,
dataView: DataView
): EventAnnotationConfig | undefined => {
if (annotation.query_string) {
const extraFields = annotation.fields
? annotation.fields
?.replace(/\s/g, '')
?.split(',')
.map((field) => {
const dataViewField = dataView.getFieldByName(field);
return dataViewField && dataViewField.aggregatable ? field : undefined;
})
.filter(nonNullable)
: undefined;
return {
type: 'query',
id: annotation.id,
label: 'Event',
key: {
type: 'point_in_time',
},
color: new Color(transparentize(annotation.color || euiLightVars.euiColorAccent, 1)).hex(),
timeField: annotation.time_field,
icon:
annotation.icon &&
ICON_TYPES_MAP[annotation.icon] &&
typeof ICON_TYPES_MAP[annotation.icon] === 'string'
? ICON_TYPES_MAP[annotation.icon]
: 'triangle',
filter: {
type: 'kibana_query',
...annotation.query_string,
},
extraFields,
isHidden: annotation.hidden,
};
}
};

View file

@ -9,6 +9,7 @@
import { parseTimeShift } from '@kbn/data-plugin/common';
import { Layer } from '@kbn/visualizations-plugin/common/convert_to_lens';
import uuid from 'uuid';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { Panel } from '../../../common/types';
import { PANEL_TYPES } from '../../../common/enums';
import { getDataViewsStart } from '../../services';
@ -37,7 +38,7 @@ const excludeMetaFromLayers = (layers: Record<string, ExtendedLayer>): Record<st
};
export const convertToLens: ConvertTsvbToLensVisualization = async (model: Panel) => {
const dataViews = getDataViewsStart();
const dataViews: DataViewsPublicPluginStart = getDataViewsStart();
const extendedLayers: Record<number, ExtendedLayer> = {};
const seriesNum = model.series.filter((series) => !series.hidden).length;
@ -96,9 +97,11 @@ export const convertToLens: ConvertTsvbToLensVisualization = async (model: Panel
};
}
const configLayers = await getLayers(extendedLayers, model, dataViews);
return {
type: 'lnsXY',
layers: Object.values(excludeMetaFromLayers(extendedLayers)),
configuration: getConfiguration(model, getLayers(extendedLayers, model)),
configuration: getConfiguration(model, configLayers),
};
};

View file

@ -79,9 +79,11 @@ export const convertToLens: ConvertTsvbToLensVisualization = async (model, timeR
};
}
const configLayers = await getLayers(extendedLayers, model, dataViews);
return {
type: 'lnsXY',
layers: Object.values(excludeMetaFromLayers(extendedLayers)),
configuration: getConfiguration(model, getLayers(extendedLayers, model)),
configuration: getConfiguration(model, configLayers),
};
};

View file

@ -8,3 +8,4 @@
export * from './types';
export * from './constants';
export * from './utils';

View file

@ -9,6 +9,7 @@
import { HorizontalAlignment, Position, VerticalAlignment } from '@elastic/charts';
import { $Values } from '@kbn/utility-types';
import type { PaletteOutput } from '@kbn/coloring';
import { KibanaQueryOutput } from '@kbn/data-plugin/common';
import { LegendSize } from '../../constants';
export const XYCurveTypes = {
@ -90,7 +91,33 @@ export interface XYReferenceLineLayerConfig {
layerType: 'referenceLine';
}
export type XYLayerConfig = XYDataLayerConfig | XYReferenceLineLayerConfig;
export interface EventAnnotationConfig {
id: string;
filter: KibanaQueryOutput;
timeField?: string;
extraFields?: string[];
label: string;
color?: string;
isHidden?: boolean;
icon?: string;
type: 'query';
key: {
type: 'point_in_time';
};
}
export interface XYAnnotationsLayerConfig {
layerId: string;
annotations: EventAnnotationConfig[];
ignoreGlobalFilters: boolean;
layerType: 'annotations';
indexPatternId: string;
}
export type XYLayerConfig =
| XYDataLayerConfig
| XYReferenceLineLayerConfig
| XYAnnotationsLayerConfig;
export interface AxesSettingsConfig {
x: boolean;

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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 { XYAnnotationsLayerConfig, XYLayerConfig } from './types';
export const isAnnotationsLayer = (
layer: Pick<XYLayerConfig, 'layerType'>
): layer is XYAnnotationsLayerConfig => layer.layerType === 'annotations';

View file

@ -203,7 +203,7 @@ export async function mountApp(
});
}
};
// get state from location, used for nanigating from Visualize/Discover to Lens
// get state from location, used for navigating from Visualize/Discover to Lens
const initialContext =
historyLocationState &&
(historyLocationState.type === ACTION_VISUALIZE_LENS_FIELD ||

View file

@ -13,6 +13,7 @@ import { difference } from 'lodash';
import type { DataViewsContract, DataViewSpec } from '@kbn/data-views-plugin/public';
import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common';
import { isAnnotationsLayer } from '@kbn/visualizations-plugin/common/convert_to_lens';
import {
Datasource,
DatasourceLayers,
@ -53,6 +54,9 @@ function getIndexPatterns(
for (const { indexPatternId } of initialContext.layers) {
indexPatternIds.push(indexPatternId);
}
for (const l of initialContext.configuration.layers) {
if (isAnnotationsLayer(l)) indexPatternIds.push(l.indexPatternId);
}
} else {
indexPatternIds.push(initialContext.dataViewSpec.id!);
}
@ -209,6 +213,7 @@ export async function initializeSources(
visualizationMap,
visualizationState,
references,
initialContext,
}),
};
}
@ -217,16 +222,19 @@ export function initializeVisualization({
visualizationMap,
visualizationState,
references,
initialContext,
}: {
visualizationState: VisualizationState;
visualizationMap: VisualizationMap;
references?: SavedObjectReference[];
initialContext?: VisualizeFieldContext | VisualizeEditorContext;
}) {
if (visualizationState?.activeId) {
return (
visualizationMap[visualizationState.activeId]?.fromPersistableState?.(
visualizationState.state,
references
references,
initialContext
) ?? visualizationState.state
);
}

View file

@ -917,7 +917,11 @@ export interface Visualization<T = unknown, P = unknown> {
/** Visualizations can have references as well */
getPersistableState?: (state: T) => { state: P; savedObjectReferences: SavedObjectReference[] };
/** Hydrate from persistable state and references to final state */
fromPersistableState?: (state: P, references?: SavedObjectReference[]) => T;
fromPersistableState?: (
state: P,
references?: SavedObjectReference[],
initialContext?: VisualizeFieldContext | VisualizeEditorContext
) => T;
/** Frame needs to know which layers the visualization is currently using */
getLayerIds: (state: T) => string[];
/** Reset button on each layer triggers this */

View file

@ -10,11 +10,13 @@ import { EuiIconType } from '@elastic/eui/src/components/icon/icon';
import type { SavedObjectReference } from '@kbn/core/public';
import { isQueryAnnotationConfig } from '@kbn/event-annotation-plugin/public';
import { i18n } from '@kbn/i18n';
import { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public';
import { validateQuery } from '../../shared_components';
import type {
FramePublicAPI,
DatasourcePublicAPI,
VisualizationDimensionGroupConfig,
VisualizeEditorContext,
} from '../../types';
import {
visualizationTypes,
@ -26,6 +28,7 @@ import {
XYState,
XYPersistedState,
State,
XYAnnotationLayerConfig,
} from './types';
import { getDataLayers, isAnnotationsLayer, isDataLayer } from './visualization_helpers';
@ -140,11 +143,13 @@ export function extractReferences(state: XYState) {
export function injectReferences(
state: XYPersistedState,
references?: SavedObjectReference[]
references?: SavedObjectReference[],
initialContext?: VisualizeFieldContext | VisualizeEditorContext
): XYState {
if (!references || !references.length) {
return state as XYState;
}
const fallbackIndexPatternId = references.find(({ type }) => type === 'index-pattern')!.id;
return {
...state,
@ -155,6 +160,7 @@ export function injectReferences(
return {
...layer,
indexPatternId:
getIndexPatternIdFromInitialContext(layer, initialContext) ||
references.find(({ name }) => name === getLayerReferenceName(layer.layerId))?.id ||
fallbackIndexPatternId,
};
@ -162,6 +168,15 @@ export function injectReferences(
};
}
function getIndexPatternIdFromInitialContext(
layer: XYAnnotationLayerConfig,
initialContext?: VisualizeFieldContext | VisualizeEditorContext
) {
if (initialContext && 'isVisualizeAction' in initialContext) {
return layer && 'indexPatternId' in layer ? layer.indexPatternId : undefined;
}
}
export function validateColumn(
state: State,
frame: Pick<FramePublicAPI, 'dataViews'>,

View file

@ -184,8 +184,8 @@ export const getXyVisualization = ({
return extractReferences(state);
},
fromPersistableState(state, references) {
return injectReferences(state, references);
fromPersistableState(state, references, initialContext) {
return injectReferences(state, references, initialContext);
},
getDescription,

View file

@ -82,6 +82,12 @@ export const annotationsIconSet: IconSet<AvailableAnnotationIcon> = [
value: 'starEmpty',
label: i18n.translate('xpack.lens.xyChart.iconSelect.starLabel', { defaultMessage: 'Star' }),
},
{
value: 'starFilled',
label: i18n.translate('xpack.lens.xyChart.iconSelect.starFilledLabel', {
defaultMessage: 'Star filled',
}),
},
{
value: 'tag',
label: i18n.translate('xpack.lens.xyChart.iconSelect.tagIconLabel', {