mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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>
This commit is contained in:
parent
7db838e0b8
commit
d2a484c5bd
12 changed files with 335 additions and 22 deletions
|
@ -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 =>
|
||||
|
|
|
@ -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<string, IndexPatternField> = {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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(
|
||||
|
|
|
@ -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<string, React.ComponentType<TimeseriesVi
|
|||
|
||||
export interface TimeseriesVisProps {
|
||||
model: TimeseriesVisParams;
|
||||
onBrush: (gte: string, lte: string) => void;
|
||||
onBrush: (gte: string, lte: string, series: PanelData[]) => Promise<void>;
|
||||
onUiState: (
|
||||
field: string,
|
||||
value: {
|
||||
|
|
|
@ -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 = {
|
|
@ -100,7 +100,7 @@ export const TimeSeries = ({
|
|||
return;
|
||||
}
|
||||
const [min, max] = x;
|
||||
onBrush(min, max);
|
||||
onBrush(min, max, series);
|
||||
};
|
||||
|
||||
const getSeriesColor = useCallback(
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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: [] },
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue