From be25e9c7bfe160146b2a712c75981f296d119311 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Fri, 2 Apr 2021 15:51:09 +0300 Subject: [PATCH] [TSVB] Enables url drilldowns for range selection (#95296) * Temp research code * Make it work * Cleanup * Convert series to datatable * Remove unecessary log * Minor * Fix types problem * Add unit tests * Take under consideration the override index pattern setting * Implement brush event for dual mode * Move indexpatterns fetch outside the loop Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../vis_type_timeseries/common/types.ts | 3 + .../lib/convert_series_to_datatable.test.ts | 185 ++++++++++++++++++ .../lib/convert_series_to_datatable.ts | 113 +++++++++++ .../components/timeseries_visualization.tsx | 42 ++-- .../application/components/vis_types/index.ts | 4 +- .../constants/{chart.js => chart.ts} | 0 .../constants/{icons.js => icons.ts} | 3 +- .../constants/{index.js => index.ts} | 0 .../visualizations/views/timeseries/index.js | 2 +- .../public/metrics_type.ts | 2 +- .../server/lib/vis_data/helpers/get_splits.js | 1 + .../lib/vis_data/helpers/get_splits.test.js | 2 + 12 files changed, 335 insertions(+), 22 deletions(-) create mode 100644 src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_datatable.test.ts create mode 100644 src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_datatable.ts rename src/plugins/vis_type_timeseries/public/application/visualizations/constants/{chart.js => chart.ts} (100%) rename src/plugins/vis_type_timeseries/public/application/visualizations/constants/{icons.js => icons.ts} (97%) rename src/plugins/vis_type_timeseries/public/application/visualizations/constants/{index.js => index.ts} (100%) diff --git a/src/plugins/vis_type_timeseries/common/types.ts b/src/plugins/vis_type_timeseries/common/types.ts index 4aa69be3466084..74e247b7af06db 100644 --- a/src/plugins/vis_type_timeseries/common/types.ts +++ b/src/plugins/vis_type_timeseries/common/types.ts @@ -63,6 +63,9 @@ export interface PanelData { id: string; label: string; data: Array<[number, number]>; + seriesId: string; + splitByLabel: string; + isSplitByTerms: boolean; } export const isVisTableData = (data: TimeseriesVisData): data is TableData => diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_datatable.test.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_datatable.test.ts new file mode 100644 index 00000000000000..df0874fdd73ec2 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_datatable.test.ts @@ -0,0 +1,185 @@ +/* + * 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 { IndexPattern, IndexPatternField } from 'src/plugins/data/public'; +import { PanelData } from '../../../../common/types'; +import { TimeseriesVisParams } from '../../../types'; +import { convertSeriesToDataTable, addMetaToColumns } from './convert_series_to_datatable'; + +jest.mock('../../../services', () => { + return { + getDataStart: jest.fn(() => { + return { + indexPatterns: jest.fn(), + }; + }), + }; +}); + +describe('convert series to datatables', () => { + let indexPattern: IndexPattern; + + beforeEach(() => { + const fieldMap: Record = { + test1: { name: 'test1', spec: { type: 'date' } } as IndexPatternField, + test2: { name: 'test2' } as IndexPatternField, + test3: { name: 'test3', spec: { type: 'boolean' } } as IndexPatternField, + }; + + const getFieldByName = (name: string): IndexPatternField | undefined => fieldMap[name]; + indexPattern = { + id: 'index1', + title: 'index1', + timeFieldName: 'timestamp', + getFieldByName, + } as IndexPattern; + }); + + describe('addMetaColumns()', () => { + test('adds the correct meta to a date column', () => { + const columns = [{ id: 0, name: 'test1', isSplit: false }]; + const columnsWithMeta = addMetaToColumns(columns, indexPattern, 'count'); + expect(columnsWithMeta).toEqual([ + { + id: '0', + meta: { + field: 'test1', + index: 'index1', + source: 'esaggs', + sourceParams: { + enabled: true, + indexPatternId: 'index1', + type: 'date_histogram', + }, + type: 'date', + }, + name: 'test1', + }, + ]); + }); + + test('adds the correct meta to a non date column', () => { + const columns = [{ id: 1, name: 'Average of test2', isSplit: false }]; + const columnsWithMeta = addMetaToColumns(columns, indexPattern, 'avg'); + expect(columnsWithMeta).toEqual([ + { + id: '1', + meta: { + field: 'Average of test2', + index: 'index1', + source: 'esaggs', + sourceParams: { + enabled: true, + indexPatternId: 'index1', + type: 'avg', + }, + type: 'number', + }, + name: 'Average of test2', + }, + ]); + }); + + test('adds the correct meta for a split column', () => { + const columns = [{ id: 2, name: 'test3', isSplit: true }]; + const columnsWithMeta = addMetaToColumns(columns, indexPattern, 'avg'); + expect(columnsWithMeta).toEqual([ + { + id: '2', + meta: { + field: 'test3', + index: 'index1', + source: 'esaggs', + sourceParams: { + enabled: true, + indexPatternId: 'index1', + type: 'terms', + }, + type: 'boolean', + }, + name: 'test3', + }, + ]); + }); + }); + + describe('convertSeriesToDataTable()', () => { + const model = { + series: [ + { + formatter: 'number', + id: 'series1', + label: '', + line_width: 1, + metrics: [ + { + field: 'test2', + id: 'series1', + type: 'avg', + }, + ], + split_mode: 'terms', + terms_field: 'Cancelled', + type: 'timeseries', + }, + ], + } as TimeseriesVisParams; + const series = ([ + { + id: 'series1:0', + label: 0, + splitByLabel: 'Average of test2', + labelFormatted: 'false', + data: [ + [1616454000000, 0], + [1616457600000, 5], + [1616461200000, 7], + [1616464800000, 8], + ], + seriesId: 'series1', + isSplitByTerms: true, + }, + { + id: 'series1:1', + label: 1, + splitByLabel: 'Average of test2', + labelFormatted: 'true', + data: [ + [1616454000000, 10], + [1616457600000, 12], + [1616461200000, 1], + [1616464800000, 14], + ], + seriesId: 'series1', + isSplitByTerms: true, + }, + ] as unknown) as PanelData[]; + test('creates one table for one layer series with the correct columns', async () => { + const tables = await convertSeriesToDataTable(model, series, indexPattern); + expect(Object.keys(tables).sort()).toEqual([model.series[0].id].sort()); + + expect(tables.series1.columns.length).toEqual(3); + expect(tables.series1.rows.length).toEqual(8); + }); + + test('the table rows for a series with term aggregation should be a combination of the different terms', async () => { + const tables = await convertSeriesToDataTable(model, series, indexPattern); + expect(Object.keys(tables).sort()).toEqual([model.series[0].id].sort()); + + expect(tables.series1.rows.length).toEqual(8); + const expected1 = series[0].data.map((d) => { + d.push(parseInt(series[0].label, 10)); + return d; + }); + const expected2 = series[1].data.map((d) => { + d.push(parseInt(series[1].label, 10)); + return d; + }); + expect(tables.series1.rows).toEqual([...expected1, ...expected2]); + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_datatable.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_datatable.ts new file mode 100644 index 00000000000000..164d93e490db1f --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_datatable.ts @@ -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 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 { IndexPattern } from 'src/plugins/data/public'; +import { + Datatable, + DatatableRow, + DatatableColumn, + DatatableColumnType, +} from 'src/plugins/expressions/public'; +import { TimeseriesVisParams } from '../../../types'; +import { PanelData } from '../../../../common/types'; +import { fetchIndexPattern } from '../../../../common/index_patterns_utils'; +import { getDataStart } from '../../../services'; +import { X_ACCESSOR_INDEX } from '../../visualizations/constants'; + +interface TSVBTables { + [key: string]: Datatable; +} + +interface TSVBColumns { + id: number; + name: string; + isSplit: boolean; +} + +export const addMetaToColumns = ( + columns: TSVBColumns[], + indexPattern: IndexPattern, + metricsType: string +): DatatableColumn[] => { + return columns.map((column) => { + const field = indexPattern.getFieldByName(column.name); + const type = (field?.spec.type as DatatableColumnType) || 'number'; + const cleanedColumn = { + id: column.id.toString(), + name: column.name, + meta: { + type, + field: column.name, + index: indexPattern.title, + source: 'esaggs', + sourceParams: { + enabled: true, + indexPatternId: indexPattern?.id, + type: type === 'date' ? 'date_histogram' : column.isSplit ? 'terms' : metricsType, + }, + }, + }; + return cleanedColumn; + }); +}; + +export const convertSeriesToDataTable = async ( + model: TimeseriesVisParams, + series: PanelData[], + initialIndexPattern: IndexPattern +) => { + const tables: TSVBTables = {}; + const { indexPatterns } = getDataStart(); + for (let layerIdx = 0; layerIdx < model.series.length; layerIdx++) { + const layer = model.series[layerIdx]; + let usedIndexPattern = initialIndexPattern; + // The user can overwrite the index pattern of a layer. + // In that case, the index pattern should be fetched again. + if (layer.override_index_pattern) { + const { indexPattern } = await fetchIndexPattern(layer.series_index_pattern, indexPatterns); + if (indexPattern) { + usedIndexPattern = indexPattern; + } + } + const isGroupedByTerms = layer.split_mode === 'terms'; + const seriesPerLayer = series.filter((s) => s.seriesId === layer.id); + let id = X_ACCESSOR_INDEX; + + const columns: TSVBColumns[] = [ + { id, name: usedIndexPattern.timeFieldName || '', isSplit: false }, + ]; + if (seriesPerLayer.length) { + id++; + columns.push({ id, name: seriesPerLayer[0].splitByLabel, isSplit: false }); + // Adds an extra column, if the layer is split by terms aggregation + if (isGroupedByTerms) { + id++; + columns.push({ id, name: layer.terms_field || '', isSplit: true }); + } + } + const columnsWithMeta = addMetaToColumns(columns, usedIndexPattern, layer.metrics[0].type); + + let rows: DatatableRow[] = []; + for (let j = 0; j < seriesPerLayer.length; j++) { + const data = seriesPerLayer[j].data.map((rowData) => { + const row: DatatableRow = [rowData[0], rowData[1]]; + // If the layer is split by terms aggregation, the data array should also contain the split value. + if (isGroupedByTerms) { + row.push(seriesPerLayer[j].label); + } + return row; + }); + rows = [...rows, ...data]; + } + tables[layer.id] = { + type: 'datatable', + rows, + columns: columnsWithMeta, + }; + } + return tables; +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx b/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx index ad4949259cfaf1..7fba2e1cb701fc 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx @@ -21,8 +21,12 @@ import { PaletteRegistry } from 'src/plugins/charts/public'; // @ts-expect-error import { ErrorComponent } from './error'; import { TimeseriesVisTypes } from './vis_types'; +import { TimeseriesVisData, PanelData, isVisSeriesData } from '../../../common/types'; +import { fetchIndexPattern } from '../../../common/index_patterns_utils'; import { TimeseriesVisParams } from '../../types'; -import { isVisSeriesData, TimeseriesVisData } from '../../../common/types'; +import { getDataStart } from '../../services'; +import { convertSeriesToDataTable } from './lib/convert_series_to_datatable'; +import { X_ACCESSOR_INDEX } from '../visualizations/constants'; import { LastValueModeIndicator } from './last_value_mode_indicator'; import { getInterval } from './lib/get_interval'; import { AUTO_INTERVAL } from '../../../common/constants'; @@ -51,25 +55,29 @@ function TimeseriesVisualization({ palettesService, }: TimeseriesVisualizationProps) { const onBrush = useCallback( - (gte: string, lte: string) => { - handlers.event({ - name: 'applyFilter', + async (gte: string, lte: string, series: PanelData[]) => { + const indexPatternValue = model.index_pattern || ''; + const { indexPatterns } = getDataStart(); + const { indexPattern } = await fetchIndexPattern(indexPatternValue, indexPatterns); + + const tables = indexPattern + ? await convertSeriesToDataTable(model, series, indexPattern) + : null; + const table = tables?.[model.series[0].id]; + + const range: [number, number] = [parseInt(gte, 10), parseInt(lte, 10)]; + const event = { data: { - timeFieldName: '*', - filters: [ - { - range: { - '*': { - gte, - lte, - }, - }, - }, - ], + table, + column: X_ACCESSOR_INDEX, + range, + timeFieldName: indexPattern?.timeFieldName, }, - }); + name: 'brush', + }; + handlers.event(event); }, - [handlers] + [handlers, model] ); const handleUiState = useCallback( diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/index.ts b/src/plugins/vis_type_timeseries/public/application/components/vis_types/index.ts index 0e169c50e4db66..34476413524681 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/index.ts +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/index.ts @@ -13,7 +13,7 @@ import { PersistedState } from 'src/plugins/visualizations/public'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { TimeseriesVisParams } from '../../../types'; -import { TimeseriesVisData } from '../../../../common/types'; +import { TimeseriesVisData, PanelData } from '../../../../common/types'; /** * Lazy load each visualization type, since the only one is presented on the screen at the same time. @@ -44,7 +44,7 @@ export const TimeseriesVisTypes: Record void; + onBrush: (gte: string, lte: string, series: PanelData[]) => Promise; onUiState: ( field: string, value: { diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/constants/chart.js b/src/plugins/vis_type_timeseries/public/application/visualizations/constants/chart.ts similarity index 100% rename from src/plugins/vis_type_timeseries/public/application/visualizations/constants/chart.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/constants/chart.ts diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/constants/icons.js b/src/plugins/vis_type_timeseries/public/application/visualizations/constants/icons.ts similarity index 97% rename from src/plugins/vis_type_timeseries/public/application/visualizations/constants/icons.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/constants/icons.ts index 1bc98c6c2a7220..5fd6933fcef01d 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/constants/icons.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/constants/icons.ts @@ -5,8 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +// @ts-expect-error import { bombIcon } from '../../components/svg/bomb_icon'; +// @ts-expect-error import { fireIcon } from '../../components/svg/fire_icon'; export const ICON_NAMES = { diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/constants/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/constants/index.ts similarity index 100% rename from src/plugins/vis_type_timeseries/public/application/visualizations/constants/index.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/constants/index.ts diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js index 537344a6da39a8..a90faea50f22a9 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js @@ -100,7 +100,7 @@ export const TimeSeries = ({ return; } const [min, max] = x; - onBrush(min, max); + onBrush(min, max, series); }; const getSeriesColor = useCallback( diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts index 5d5e082b2b7bbb..4e45ddf4347719 100644 --- a/src/plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -74,7 +74,7 @@ export const metricsVisDefinition = { }, toExpressionAst, getSupportedTriggers: () => { - return [VIS_EVENT_TO_TRIGGER.applyFilter]; + return [VIS_EVENT_TO_TRIGGER.brush]; }, inspectorAdapters: {}, getUsedIndexPattern: async (params: VisParams) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_splits.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_splits.js index f22226e03a5aab..268c26115233ec 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_splits.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_splits.js @@ -45,6 +45,7 @@ export async function getSplits(resp, panel, series, meta, extractFields) { const bucket = _.get(resp, `aggregations.${series.id}.buckets.${filter.id}`); bucket.id = `${series.id}:${filter.id}`; bucket.key = filter.id; + bucket.splitByLabel = splitByLabel; bucket.color = filter.color; bucket.label = filter.label || filter.filter.query || '*'; bucket.meta = meta; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_splits.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_splits.test.js index e2ae404d98970e..d26bfa9be893e0 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_splits.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_splits.test.js @@ -257,6 +257,7 @@ describe('getSplits(resp, panel, series)', () => { key: 'filter-1', label: '200s', meta: { bucketSize: 10 }, + splitByLabel: 'Count', color: '#F00', timeseries: { buckets: [] }, }, @@ -264,6 +265,7 @@ describe('getSplits(resp, panel, series)', () => { id: 'SERIES:filter-2', key: 'filter-2', label: '300s', + splitByLabel: 'Count', meta: { bucketSize: 10 }, color: '#0F0', timeseries: { buckets: [] },