Commit 8f4be08b by Andrej Ocenas Committed by GitHub

Explore: Support wide data frames (#28393)

* Change how isTimeSeries work

* Simplify the decorators and update tests
parent 0bb33839
...@@ -5,6 +5,12 @@ export const initialState: AppNotificationsState = { ...@@ -5,6 +5,12 @@ export const initialState: AppNotificationsState = {
appNotifications: [] as AppNotification[], appNotifications: [] as AppNotification[],
}; };
/**
* Reducer and action to show toast notifications of various types (success, warnings, errors etc). Use to show
* transient info to user, like errors that cannot be otherwise handled or success after an action.
*
* Use factory functions in core/copy/appNotifications to create the payload.
*/
const appNotificationsSlice = createSlice({ const appNotificationsSlice = createSlice({
name: 'appNotifications', name: 'appNotifications',
initialState, initialState,
......
// Libraries // Libraries
import { map, throttleTime } from 'rxjs/operators'; import { map, mergeMap, throttleTime } from 'rxjs/operators';
import { identity } from 'rxjs'; import { identity } from 'rxjs';
import { PayloadAction } from '@reduxjs/toolkit'; import { PayloadAction } from '@reduxjs/toolkit';
import { DataSourceSrv } from '@grafana/runtime'; import { DataSourceSrv } from '@grafana/runtime';
...@@ -84,7 +84,7 @@ import { ...@@ -84,7 +84,7 @@ import {
} from './actionTypes'; } from './actionTypes';
import { getTimeZone } from 'app/features/profile/state/selectors'; import { getTimeZone } from 'app/features/profile/state/selectors';
import { getShiftedTimeRange } from 'app/core/utils/timePicker'; import { getShiftedTimeRange } from 'app/core/utils/timePicker';
import { updateLocation } from '../../../core/actions'; import { notifyApp, updateLocation } from '../../../core/actions';
import { getTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv'; import { getTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv';
import { preProcessPanelData, runRequest } from '../../dashboard/state/runRequest'; import { preProcessPanelData, runRequest } from '../../dashboard/state/runRequest';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
...@@ -96,6 +96,7 @@ import { ...@@ -96,6 +96,7 @@ import {
decorateWithLogsResult, decorateWithLogsResult,
decorateWithTableResult, decorateWithTableResult,
} from '../utils/decorators'; } from '../utils/decorators';
import { createErrorNotification } from '../../../core/copy/appNotification';
/** /**
* Adds a query row after the row with the given index. * Adds a query row after the row with the given index.
...@@ -427,6 +428,8 @@ export const runQueries = (exploreId: ExploreId): ThunkResult<void> => { ...@@ -427,6 +428,8 @@ export const runQueries = (exploreId: ExploreId): ThunkResult<void> => {
queryResponse, queryResponse,
querySubscription, querySubscription,
history, history,
refreshInterval,
absoluteRange,
} = exploreItemState; } = exploreItemState;
if (!hasNonEmptyQuery(queries)) { if (!hasNonEmptyQuery(queries)) {
...@@ -473,47 +476,54 @@ export const runQueries = (exploreId: ExploreId): ThunkResult<void> => { ...@@ -473,47 +476,54 @@ export const runQueries = (exploreId: ExploreId): ThunkResult<void> => {
// actually can see what is happening. // actually can see what is happening.
live ? throttleTime(500) : identity, live ? throttleTime(500) : identity,
map((data: PanelData) => preProcessPanelData(data, queryResponse)), map((data: PanelData) => preProcessPanelData(data, queryResponse)),
decorateWithGraphLogsTraceAndTable(getState().explore[exploreId].datasourceInstance), map(decorateWithGraphLogsTraceAndTable),
decorateWithGraphResult(), map(decorateWithGraphResult),
decorateWithTableResult(), map(decorateWithLogsResult({ absoluteRange, refreshInterval })),
decorateWithLogsResult(getState().explore[exploreId]) mergeMap(decorateWithTableResult)
) )
.subscribe(data => { .subscribe(
if (!data.error && firstResponse) { data => {
// Side-effect: Saving history in localstorage if (!data.error && firstResponse) {
const nextHistory = updateHistory(history, datasourceId, queries); // Side-effect: Saving history in localstorage
const nextRichHistory = addToRichHistory( const nextHistory = updateHistory(history, datasourceId, queries);
richHistory || [], const nextRichHistory = addToRichHistory(
datasourceId, richHistory || [],
datasourceName, datasourceId,
queries, datasourceName,
false, queries,
'', false,
'' '',
); ''
dispatch(historyUpdatedAction({ exploreId, history: nextHistory })); );
dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); dispatch(historyUpdatedAction({ exploreId, history: nextHistory }));
dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory }));
// We save queries to the URL here so that only successfully run queries change the URL.
dispatch(stateSave()); // We save queries to the URL here so that only successfully run queries change the URL.
} dispatch(stateSave());
}
firstResponse = false; firstResponse = false;
dispatch(queryStreamUpdatedAction({ exploreId, response: data })); dispatch(queryStreamUpdatedAction({ exploreId, response: data }));
// Keep scanning for results if this was the last scanning transaction // Keep scanning for results if this was the last scanning transaction
if (getState().explore[exploreId].scanning) { if (getState().explore[exploreId].scanning) {
if (data.state === LoadingState.Done && data.series.length === 0) { if (data.state === LoadingState.Done && data.series.length === 0) {
const range = getShiftedTimeRange(-1, getState().explore[exploreId].range); const range = getShiftedTimeRange(-1, getState().explore[exploreId].range);
dispatch(updateTime({ exploreId, absoluteRange: range })); dispatch(updateTime({ exploreId, absoluteRange: range }));
dispatch(runQueries(exploreId)); dispatch(runQueries(exploreId));
} else { } else {
// We can stop scanning if we have a result // We can stop scanning if we have a result
dispatch(scanStopAction({ exploreId })); dispatch(scanStopAction({ exploreId }));
}
} }
},
error => {
dispatch(notifyApp(createErrorNotification('Query processing error', error)));
dispatch(changeLoadingStateAction({ exploreId, loadingState: LoadingState.Error }));
console.error(error);
} }
}); );
dispatch(queryStoreSubscriptionAction({ exploreId, querySubscription: newQuerySub })); dispatch(queryStoreSubscriptionAction({ exploreId, querySubscription: newQuerySub }));
}; };
......
...@@ -3,15 +3,12 @@ jest.mock('@grafana/data/src/datetime/formatter', () => ({ ...@@ -3,15 +3,12 @@ jest.mock('@grafana/data/src/datetime/formatter', () => ({
dateTimeFormatTimeAgo: (ts: any) => 'fromNow() jest mocked', dateTimeFormatTimeAgo: (ts: any) => 'fromNow() jest mocked',
})); }));
import { of } from 'rxjs';
import { import {
ArrayVector, ArrayVector,
DataFrame, DataFrame,
DataQueryRequest, DataQueryRequest,
DataSourceApi,
FieldType, FieldType,
LoadingState, LoadingState,
observableTester,
PanelData, PanelData,
TimeRange, TimeRange,
toDataFrame, toDataFrame,
...@@ -24,7 +21,7 @@ import { ...@@ -24,7 +21,7 @@ import {
decorateWithTableResult, decorateWithTableResult,
} from './decorators'; } from './decorators';
import { describe } from '../../../../test/lib/common'; import { describe } from '../../../../test/lib/common';
import { ExploreItemState, ExplorePanelData } from 'app/types'; import { ExplorePanelData } from 'app/types';
import TableModel from 'app/core/table_model'; import TableModel from 'app/core/table_model';
const getTestContext = () => { const getTestContext = () => {
...@@ -37,6 +34,7 @@ const getTestContext = () => { ...@@ -37,6 +34,7 @@ const getTestContext = () => {
fields: [ fields: [
{ name: 'time', type: FieldType.time, values: [100, 200, 300] }, { name: 'time', type: FieldType.time, values: [100, 200, 300] },
{ name: 'A-series', type: FieldType.number, values: [4, 5, 6] }, { name: 'A-series', type: FieldType.number, values: [4, 5, 6] },
{ name: 'B-series', type: FieldType.number, values: [7, 8, 9] },
], ],
}); });
...@@ -86,450 +84,337 @@ const createExplorePanelData = (args: Partial<ExplorePanelData>): ExplorePanelDa ...@@ -86,450 +84,337 @@ const createExplorePanelData = (args: Partial<ExplorePanelData>): ExplorePanelDa
}; };
describe('decorateWithGraphLogsTraceAndTable', () => { describe('decorateWithGraphLogsTraceAndTable', () => {
describe('when used without error', () => { it('should correctly classify the dataFrames', () => {
it('then the result should be correct', done => { const { table, logs, timeSeries, emptyTable } = getTestContext();
const { table, logs, timeSeries, emptyTable } = getTestContext(); const series = [table, logs, timeSeries, emptyTable];
const datasourceInstance = ({ meta: { id: 'prometheus' } } as unknown) as DataSourceApi; const panelData: PanelData = {
const series = [table, logs, timeSeries, emptyTable]; series,
const panelData: PanelData = { state: LoadingState.Done,
series, timeRange: ({} as unknown) as TimeRange,
state: LoadingState.Done, };
timeRange: ({} as unknown) as TimeRange,
}; expect(decorateWithGraphLogsTraceAndTable(panelData)).toEqual({
series,
observableTester().subscribeAndExpectOnNext({ state: LoadingState.Done,
observable: of(panelData).pipe(decorateWithGraphLogsTraceAndTable(datasourceInstance)), timeRange: {},
expect: value => { graphFrames: [timeSeries],
expect(value).toEqual({ tableFrames: [table, emptyTable],
series, logsFrames: [logs],
state: LoadingState.Done, traceFrames: [],
timeRange: {}, graphResult: null,
graphFrames: [timeSeries], tableResult: null,
tableFrames: [table, emptyTable], logsResult: null,
logsFrames: [logs],
traceFrames: [],
graphResult: null,
tableResult: null,
logsResult: null,
});
},
done,
});
}); });
}); });
describe('when used without frames', () => { it('should handle empty array', () => {
it('then the result should be correct', done => { const series: DataFrame[] = [];
const datasourceInstance = ({ meta: { id: 'prometheus' } } as unknown) as DataSourceApi; const panelData: PanelData = {
const series: DataFrame[] = []; series,
const panelData: PanelData = { state: LoadingState.Done,
series, timeRange: ({} as unknown) as TimeRange,
state: LoadingState.Done, };
timeRange: ({} as unknown) as TimeRange,
}; expect(decorateWithGraphLogsTraceAndTable(panelData)).toEqual({
series: [],
observableTester().subscribeAndExpectOnNext({ state: LoadingState.Done,
observable: of(panelData).pipe(decorateWithGraphLogsTraceAndTable(datasourceInstance)), timeRange: {},
expect: value => { graphFrames: [],
expect(value).toEqual({ tableFrames: [],
series: [], logsFrames: [],
state: LoadingState.Done, traceFrames: [],
timeRange: {}, graphResult: null,
graphFrames: [], tableResult: null,
tableFrames: [], logsResult: null,
logsFrames: [],
traceFrames: [],
graphResult: null,
tableResult: null,
logsResult: null,
});
},
done,
});
}); });
}); });
describe('when used with an error', () => { it('should handle query error', () => {
it('then the result should be correct', done => { const { timeSeries, logs, table } = getTestContext();
const { timeSeries, logs, table } = getTestContext(); const series: DataFrame[] = [timeSeries, logs, table];
const datasourceInstance = ({ meta: { id: 'prometheus' } } as unknown) as DataSourceApi; const panelData: PanelData = {
const series: DataFrame[] = [timeSeries, logs, table]; series,
const panelData: PanelData = { error: {},
series, state: LoadingState.Error,
error: {}, timeRange: ({} as unknown) as TimeRange,
state: LoadingState.Error, };
timeRange: ({} as unknown) as TimeRange,
}; expect(decorateWithGraphLogsTraceAndTable(panelData)).toEqual({
series: [timeSeries, logs, table],
observableTester().subscribeAndExpectOnNext({ error: {},
observable: of(panelData).pipe(decorateWithGraphLogsTraceAndTable(datasourceInstance)), state: LoadingState.Error,
expect: value => { timeRange: {},
expect(value).toEqual({ graphFrames: [],
series: [timeSeries, logs, table], tableFrames: [],
error: {}, logsFrames: [],
state: LoadingState.Error, traceFrames: [],
timeRange: {}, graphResult: null,
graphFrames: [], tableResult: null,
tableFrames: [], logsResult: null,
logsFrames: [],
traceFrames: [],
graphResult: null,
tableResult: null,
logsResult: null,
});
},
done,
});
}); });
}); });
}); });
describe('decorateWithGraphResult', () => { describe('decorateWithGraphResult', () => {
describe('when used without error', () => { it('should process the graph dataFrames', () => {
it('then the graphResult should be correct', done => { const { timeSeries } = getTestContext();
const { timeSeries } = getTestContext(); const panelData = createExplorePanelData({ graphFrames: [timeSeries] });
const timeField = timeSeries.fields[0]; console.log(decorateWithGraphResult(panelData).graphResult);
const valueField = timeSeries.fields[1]; expect(decorateWithGraphResult(panelData).graphResult).toMatchObject([
const panelData = createExplorePanelData({ graphFrames: [timeSeries] }); {
label: 'A-series',
observableTester().subscribeAndExpectOnNext({ data: [
observable: of(panelData).pipe(decorateWithGraphResult()), [100, 4],
expect: panelData => { [200, 5],
expect(panelData.graphResult![0]).toEqual({ [300, 6],
label: 'A-series', ],
color: '#7EB26D', isVisible: true,
data: [ yAxis: {
[100, 4], index: 1,
[200, 5],
[300, 6],
],
info: [],
isVisible: true,
yAxis: {
index: 1,
},
seriesIndex: 0,
timeField,
valueField,
timeStep: 100,
});
}, },
done, seriesIndex: 0,
}); timeStep: 100,
}); },
}); {
label: 'B-series',
describe('when used without error but graph frames are empty', () => { data: [
it('then the graphResult should be null', done => { [100, 7],
const panelData = createExplorePanelData({ graphFrames: [] }); [200, 8],
[300, 9],
observableTester().subscribeAndExpectOnNext({ ],
observable: of(panelData).pipe(decorateWithGraphResult()), isVisible: true,
expect: panelData => { yAxis: {
expect(panelData.graphResult).toBeNull(); index: 1,
}, },
done, seriesIndex: 1,
}); timeStep: 100,
}); },
]);
}); });
describe('when used with error', () => { it('returns null if it gets empty array', () => {
it('then the graphResult should be null', done => { const panelData = createExplorePanelData({ graphFrames: [] });
const { timeSeries } = getTestContext(); expect(decorateWithGraphResult(panelData).graphResult).toBeNull();
const panelData = createExplorePanelData({ error: {}, graphFrames: [timeSeries] }); });
observableTester().subscribeAndExpectOnNext({ it('returns null if panelData has error', () => {
observable: of(panelData).pipe(decorateWithGraphResult()), const { timeSeries } = getTestContext();
expect: panelData => { const panelData = createExplorePanelData({ error: {}, graphFrames: [timeSeries] });
expect(panelData.graphResult).toBeNull(); expect(decorateWithGraphResult(panelData).graphResult).toBeNull();
},
done,
});
});
}); });
}); });
describe('decorateWithTableResult', () => { describe('decorateWithTableResult', () => {
describe('when used without error', () => { it('should process table type dataFrame', async () => {
it('then the tableResult should be correct', done => { const { table, emptyTable } = getTestContext();
const { table, emptyTable } = getTestContext(); const panelData = createExplorePanelData({ tableFrames: [table, emptyTable] });
const panelData = createExplorePanelData({ tableFrames: [table, emptyTable] }); const panelResult = await decorateWithTableResult(panelData).toPromise();
observableTester().subscribeAndExpectOnNext({ let theResult = panelResult.tableResult;
observable: of(panelData).pipe(decorateWithTableResult()),
expect: panelData => { expect(theResult?.fields[0].name).toEqual('value');
let theResult = panelData.tableResult; expect(theResult?.fields[1].name).toEqual('time');
expect(theResult?.fields[2].name).toEqual('tsNs');
expect(theResult?.fields[0].name).toEqual('value'); expect(theResult?.fields[3].name).toEqual('message');
expect(theResult?.fields[1].name).toEqual('time'); expect(theResult?.fields[1].display).not.toBeNull();
expect(theResult?.fields[2].name).toEqual('tsNs'); expect(theResult?.length).toBe(3);
expect(theResult?.fields[3].name).toEqual('message');
expect(theResult?.fields[1].display).not.toBeNull(); // I don't understand the purpose of the code below, feels like this belongs in toDataFrame tests?
expect(theResult?.length).toBe(3); // Same data though a DataFrame
theResult = toDataFrame(
// I don't understand the purpose of the code below, feels like this belongs in toDataFrame tests? new TableModel({
// Same data though a DataFrame columns: [
theResult = toDataFrame( { text: 'value', type: 'number' },
new TableModel({ { text: 'time', type: 'time' },
columns: [ { text: 'tsNs', type: 'time' },
{ text: 'value', type: 'number' }, { text: 'message', type: 'string' },
{ text: 'time', type: 'time' }, ],
{ text: 'tsNs', type: 'time' }, rows: [
{ text: 'message', type: 'string' }, [4, 100, '100000000', 'this is a message'],
], [5, 200, '100000000', 'second message'],
rows: [ [6, 300, '100000000', 'third'],
[4, 100, '100000000', 'this is a message'], ],
[5, 200, '100000000', 'second message'], type: 'table',
[6, 300, '100000000', 'third'], })
], );
type: 'table', expect(theResult.fields[0].name).toEqual('value');
}) expect(theResult.fields[1].name).toEqual('time');
); expect(theResult.fields[2].name).toEqual('tsNs');
expect(theResult.fields[0].name).toEqual('value'); expect(theResult.fields[3].name).toEqual('message');
expect(theResult.fields[1].name).toEqual('time'); expect(theResult.fields[1].display).not.toBeNull();
expect(theResult.fields[2].name).toEqual('tsNs'); expect(theResult.length).toBe(3);
expect(theResult.fields[3].name).toEqual('message');
expect(theResult.fields[1].display).not.toBeNull();
expect(theResult.length).toBe(3);
},
done,
});
});
it('should do join transform if all series are timeseries', done => {
const tableFrames = [
toDataFrame({
name: 'A-series',
refId: 'A',
fields: [
{ name: 'Time', type: FieldType.time, values: [100, 200, 300] },
{ name: 'A-series', type: FieldType.number, values: [4, 5, 6] },
],
}),
toDataFrame({
name: 'B-series',
refId: 'B',
fields: [
{ name: 'Time', type: FieldType.time, values: [100, 200, 300] },
{ name: 'B-series', type: FieldType.number, values: [4, 5, 6] },
],
}),
];
const panelData = createExplorePanelData({ tableFrames });
observableTester().subscribeAndExpectOnNext({
observable: of(panelData).pipe(decorateWithTableResult()),
expect: panelData => {
const result = panelData.tableResult;
expect(result?.fields[0].name).toBe('Time');
expect(result?.fields[1].name).toBe('A-series');
expect(result?.fields[2].name).toBe('B-series');
expect(result?.fields[0].values.toArray()).toEqual([100, 200, 300]);
expect(result?.fields[1].values.toArray()).toEqual([4, 5, 6]);
expect(result?.fields[2].values.toArray()).toEqual([4, 5, 6]);
},
done,
});
});
it('should not override fields display property when filled', done => {
const tableFrames = [
toDataFrame({
name: 'A-series',
refId: 'A',
fields: [{ name: 'Text', type: FieldType.string, values: ['someText'] }],
}),
];
const displayFunctionMock = jest.fn();
tableFrames[0].fields[0].display = displayFunctionMock;
const panelData = createExplorePanelData({ tableFrames });
observableTester().subscribeAndExpectOnNext({
observable: of(panelData).pipe(decorateWithTableResult()),
expect: panelData => {
const data = panelData.tableResult;
expect(data?.fields[0].display).toBe(displayFunctionMock);
},
done,
});
});
}); });
describe('when used without error but table frames are empty', () => { it('should do join transform if all series are timeseries', async () => {
it('then the tableResult should be null', done => { const tableFrames = [
const panelData = createExplorePanelData({ tableFrames: [] }); toDataFrame({
name: 'A-series',
refId: 'A',
fields: [
{ name: 'Time', type: FieldType.time, values: [100, 200, 300] },
{ name: 'A-series', type: FieldType.number, values: [4, 5, 6] },
],
}),
toDataFrame({
name: 'B-series',
refId: 'B',
fields: [
{ name: 'Time', type: FieldType.time, values: [100, 200, 300] },
{ name: 'B-series', type: FieldType.number, values: [4, 5, 6] },
],
}),
];
const panelData = createExplorePanelData({ tableFrames });
const panelResult = await decorateWithTableResult(panelData).toPromise();
const result = panelResult.tableResult;
expect(result?.fields[0].name).toBe('Time');
expect(result?.fields[1].name).toBe('A-series');
expect(result?.fields[2].name).toBe('B-series');
expect(result?.fields[0].values.toArray()).toEqual([100, 200, 300]);
expect(result?.fields[1].values.toArray()).toEqual([4, 5, 6]);
expect(result?.fields[2].values.toArray()).toEqual([4, 5, 6]);
});
observableTester().subscribeAndExpectOnNext({ it('should not override fields display property when filled', async () => {
observable: of(panelData).pipe(decorateWithTableResult()), const tableFrames = [
expect: panelData => { toDataFrame({
expect(panelData.tableResult).toBeNull(); name: 'A-series',
}, refId: 'A',
done, fields: [{ name: 'Text', type: FieldType.string, values: ['someText'] }],
}); }),
}); ];
const displayFunctionMock = jest.fn();
tableFrames[0].fields[0].display = displayFunctionMock;
const panelData = createExplorePanelData({ tableFrames });
const panelResult = await decorateWithTableResult(panelData).toPromise();
expect(panelResult.tableResult?.fields[0].display).toBe(displayFunctionMock);
}); });
describe('when used with error', () => { it('should return null when passed empty array', async () => {
it('then the tableResult should be null', done => { const panelData = createExplorePanelData({ tableFrames: [] });
const { table, emptyTable } = getTestContext(); const panelResult = await decorateWithTableResult(panelData).toPromise();
const panelData = createExplorePanelData({ error: {}, tableFrames: [table, emptyTable] }); expect(panelResult.tableResult).toBeNull();
});
observableTester().subscribeAndExpectOnNext({ it('returns null if panelData has error', async () => {
observable: of(panelData).pipe(decorateWithTableResult()), const { table, emptyTable } = getTestContext();
expect: panelData => { const panelData = createExplorePanelData({ error: {}, tableFrames: [table, emptyTable] });
expect(panelData.tableResult).toBeNull(); const panelResult = await decorateWithTableResult(panelData).toPromise();
}, expect(panelResult.tableResult).toBeNull();
done,
});
});
}); });
}); });
describe('decorateWithLogsResult', () => { describe('decorateWithLogsResult', () => {
describe('when used without error', () => { it('should correctly transform logs dataFrames', () => {
it('then the logsResult should be correct', done => { const { logs } = getTestContext();
const { logs } = getTestContext(); const request = ({ timezone: 'utc', intervalMs: 60000 } as unknown) as DataQueryRequest;
const state = ({ const panelData = createExplorePanelData({ logsFrames: [logs], request });
queryIntervals: { intervalMs: 10 }, expect(decorateWithLogsResult()(panelData).logsResult).toEqual({
} as unknown) as ExploreItemState; hasUniqueLabels: false,
const request = ({ timezone: 'utc', intervalMs: 60000 } as unknown) as DataQueryRequest; meta: [],
const panelData = createExplorePanelData({ logsFrames: [logs], request }); rows: [
{
observableTester().subscribeAndExpectOnNext({ rowIndex: 0,
observable: of(panelData).pipe(decorateWithLogsResult(state)), dataFrame: logs,
expect: panelData => { entry: 'this is a message',
const theResult = panelData.logsResult; entryFieldIndex: 3,
hasAnsi: false,
expect(theResult).toEqual({ labels: {},
hasUniqueLabels: false, logLevel: 'unknown',
meta: [], raw: 'this is a message',
rows: [ searchWords: [] as string[],
{ timeEpochMs: 100,
rowIndex: 0, timeEpochNs: '100000002',
dataFrame: logs, timeFromNow: 'fromNow() jest mocked',
entry: 'this is a message', timeLocal: 'format() jest mocked',
entryFieldIndex: 3, timeUtc: 'format() jest mocked',
hasAnsi: false, uid: '0',
labels: {}, uniqueLabels: {},
logLevel: 'unknown',
raw: 'this is a message',
searchWords: [] as string[],
timeEpochMs: 100,
timeEpochNs: '100000002',
timeFromNow: 'fromNow() jest mocked',
timeLocal: 'format() jest mocked',
timeUtc: 'format() jest mocked',
uid: '0',
uniqueLabels: {},
},
{
rowIndex: 2,
dataFrame: logs,
entry: 'third',
entryFieldIndex: 3,
hasAnsi: false,
labels: {},
logLevel: 'unknown',
raw: 'third',
searchWords: [] as string[],
timeEpochMs: 100,
timeEpochNs: '100000001',
timeFromNow: 'fromNow() jest mocked',
timeLocal: 'format() jest mocked',
timeUtc: 'format() jest mocked',
uid: '2',
uniqueLabels: {},
},
{
rowIndex: 1,
dataFrame: logs,
entry: 'second message',
entryFieldIndex: 3,
hasAnsi: false,
labels: {},
logLevel: 'unknown',
raw: 'second message',
searchWords: [] as string[],
timeEpochMs: 100,
timeEpochNs: '100000000',
timeFromNow: 'fromNow() jest mocked',
timeLocal: 'format() jest mocked',
timeUtc: 'format() jest mocked',
uid: '1',
uniqueLabels: {},
},
],
series: [
{
label: 'unknown',
color: '#8e8e8e',
data: [[0, 3]],
isVisible: true,
yAxis: {
index: 1,
min: 0,
tickDecimals: 0,
},
seriesIndex: 0,
timeField: {
name: 'Time',
type: 'time',
config: {},
values: new ArrayVector([0]),
index: 0,
display: expect.anything(),
},
valueField: {
name: 'unknown',
type: 'number',
config: { unit: undefined, color: '#8e8e8e' },
values: new ArrayVector([3]),
labels: undefined,
index: 1,
display: expect.anything(),
state: expect.anything(),
},
timeStep: 0,
},
],
visibleRange: undefined,
});
}, },
done, {
}); rowIndex: 2,
}); dataFrame: logs,
}); entry: 'third',
entryFieldIndex: 3,
describe('when used without error but logs frames are empty', () => { hasAnsi: false,
it('then the graphResult should be null', done => { labels: {},
const panelData = createExplorePanelData({ logsFrames: [] }); logLevel: 'unknown',
const state = ({} as unknown) as ExploreItemState; raw: 'third',
searchWords: [] as string[],
observableTester().subscribeAndExpectOnNext({ timeEpochMs: 100,
observable: of(panelData).pipe(decorateWithLogsResult(state)), timeEpochNs: '100000001',
expect: panelData => { timeFromNow: 'fromNow() jest mocked',
expect(panelData.logsResult).toBeNull(); timeLocal: 'format() jest mocked',
timeUtc: 'format() jest mocked',
uid: '2',
uniqueLabels: {},
}, },
done, {
}); rowIndex: 1,
dataFrame: logs,
entry: 'second message',
entryFieldIndex: 3,
hasAnsi: false,
labels: {},
logLevel: 'unknown',
raw: 'second message',
searchWords: [] as string[],
timeEpochMs: 100,
timeEpochNs: '100000000',
timeFromNow: 'fromNow() jest mocked',
timeLocal: 'format() jest mocked',
timeUtc: 'format() jest mocked',
uid: '1',
uniqueLabels: {},
},
],
series: [
{
label: 'unknown',
color: '#8e8e8e',
data: [[0, 3]],
isVisible: true,
yAxis: {
index: 1,
min: 0,
tickDecimals: 0,
},
seriesIndex: 0,
timeField: {
name: 'Time',
type: 'time',
config: {},
values: new ArrayVector([0]),
index: 0,
display: expect.anything(),
},
valueField: {
name: 'unknown',
type: 'number',
config: { unit: undefined, color: '#8e8e8e' },
values: new ArrayVector([3]),
labels: undefined,
index: 1,
display: expect.anything(),
state: expect.anything(),
},
timeStep: 0,
},
],
visibleRange: undefined,
}); });
}); });
describe('when used with error', () => { it('returns null if passed empty array', () => {
it('then the graphResult should be null', done => { const panelData = createExplorePanelData({ logsFrames: [] });
const { logs } = getTestContext(); expect(decorateWithLogsResult()(panelData).logsResult).toBeNull();
const panelData = createExplorePanelData({ error: {}, logsFrames: [logs] }); });
const state = ({} as unknown) as ExploreItemState;
observableTester().subscribeAndExpectOnNext({ it('returns null if panelData has error', () => {
observable: of(panelData).pipe(decorateWithLogsResult(state)), const { logs } = getTestContext();
expect: panelData => { const panelData = createExplorePanelData({ error: {}, logsFrames: [logs] });
expect(panelData.logsResult).toBeNull(); expect(decorateWithLogsResult()(panelData).logsResult).toBeNull();
},
done,
});
});
}); });
}); });
import { MonoTypeOperatorFunction, of, OperatorFunction } from 'rxjs'; import { Observable, of } from 'rxjs';
import { map, mergeMap } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { import {
AbsoluteTimeRange,
DataFrame, DataFrame,
DataSourceApi,
FieldType, FieldType,
getDisplayProcessor, getDisplayProcessor,
PanelData, PanelData,
PreferredVisualisationType,
sortLogsResult, sortLogsResult,
standardTransformers, standardTransformers,
} from '@grafana/data'; } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { groupBy } from 'lodash';
import { ExploreItemState, ExplorePanelData } from '../../../types'; import { ExplorePanelData } from '../../../types';
import { getGraphSeriesModel } from '../../../plugins/panel/graph2/getGraphSeriesModel'; import { getGraphSeriesModel } from '../../../plugins/panel/graph2/getGraphSeriesModel';
import { dataFrameToLogsModel } from '../../../core/logs_model'; import { dataFrameToLogsModel } from '../../../core/logs_model';
import { refreshIntervalToSortOrder } from '../../../core/utils/explore'; import { refreshIntervalToSortOrder } from '../../../core/utils/explore';
export const decorateWithGraphLogsTraceAndTable = ( /**
datasourceInstance?: DataSourceApi | null * When processing response first we try to determine what kind of dataframes we got as one query can return multiple
): OperatorFunction<PanelData, ExplorePanelData> => inputStream => * dataFrames with different type of data. This is later used for type specific processing. As we use this in
inputStream.pipe( * Observable pipeline, it decorates the existing panelData to pass the results to later processing stages.
map(data => { */
if (data.error) { export const decorateWithGraphLogsTraceAndTable = (data: PanelData): ExplorePanelData => {
return { if (data.error) {
...data, return {
graphFrames: [], ...data,
tableFrames: [], graphFrames: [],
logsFrames: [], tableFrames: [],
traceFrames: [], logsFrames: [],
graphResult: null, traceFrames: [],
tableResult: null, graphResult: null,
logsResult: null, tableResult: null,
}; logsResult: null,
} };
}
const graphFrames: DataFrame[] = [];
const tableFrames: DataFrame[] = [];
const logsFrames: DataFrame[] = [];
const traceFrames: DataFrame[] = [];
for (const frame of data.series) { const graphFrames: DataFrame[] = [];
if (shouldShowInVisualisationTypeStrict(frame, 'logs')) { const tableFrames: DataFrame[] = [];
logsFrames.push(frame); const logsFrames: DataFrame[] = [];
} else if (shouldShowInVisualisationTypeStrict(frame, 'graph')) { const traceFrames: DataFrame[] = [];
for (const frame of data.series) {
switch (frame.meta?.preferredVisualisationType) {
case 'logs':
logsFrames.push(frame);
break;
case 'graph':
graphFrames.push(frame);
break;
case 'trace':
traceFrames.push(frame);
break;
case 'table':
tableFrames.push(frame);
break;
default:
if (isTimeSeries(frame)) {
graphFrames.push(frame); graphFrames.push(frame);
} else if (shouldShowInVisualisationTypeStrict(frame, 'trace')) {
traceFrames.push(frame);
} else if (shouldShowInVisualisationTypeStrict(frame, 'table')) {
tableFrames.push(frame); tableFrames.push(frame);
} else if (isTimeSeries(frame, datasourceInstance?.meta.id)) {
if (shouldShowInVisualisationType(frame, 'graph')) {
graphFrames.push(frame);
}
if (shouldShowInVisualisationType(frame, 'table')) {
tableFrames.push(frame);
}
} else { } else {
// We fallback to table if we do not have any better meta info about the dataframe. // We fallback to table if we do not have any better meta info about the dataframe.
tableFrames.push(frame); tableFrames.push(frame);
} }
} }
}
return { return {
...data, ...data,
graphFrames, graphFrames,
tableFrames, tableFrames,
logsFrames, logsFrames,
traceFrames, traceFrames,
graphResult: null, graphResult: null,
tableResult: null, tableResult: null,
logsResult: null, logsResult: null,
}; };
}) };
);
export const decorateWithGraphResult = (data: ExplorePanelData): ExplorePanelData => {
if (data.error) {
return { ...data, graphResult: null };
}
export const decorateWithGraphResult = (): MonoTypeOperatorFunction<ExplorePanelData> => inputStream => const graphResult =
inputStream.pipe( data.graphFrames.length === 0
map(data => { ? null
if (data.error) { : getGraphSeriesModel(
return { ...data, graphResult: null }; data.graphFrames,
} data.request?.timezone ?? 'browser',
{},
{ showBars: false, showLines: true, showPoints: false },
{ asTable: false, isVisible: true, placement: 'under' }
);
return { ...data, graphResult };
};
/**
* This processing returns Observable because it uses Transformer internally which result type is also Observable.
* In this case the transformer should return single result but it is possible that in the future it could return
* multiple results and so this should be used with mergeMap or similar to unbox the internal observable.
*/
export const decorateWithTableResult = (data: ExplorePanelData): Observable<ExplorePanelData> => {
if (data.error) {
return of({ ...data, tableResult: null });
}
const graphResult = if (data.tableFrames.length === 0) {
data.graphFrames.length === 0 return of({ ...data, tableResult: null });
? null }
: getGraphSeriesModel(
data.graphFrames,
data.request?.timezone ?? 'browser',
{},
{ showBars: false, showLines: true, showPoints: false },
{ asTable: false, isVisible: true, placement: 'under' }
);
return { ...data, graphResult };
})
);
export const decorateWithTableResult = (): MonoTypeOperatorFunction<ExplorePanelData> => inputStream => data.tableFrames.sort((frameA: DataFrame, frameB: DataFrame) => {
inputStream.pipe( const frameARefId = frameA.refId!;
mergeMap(data => { const frameBRefId = frameB.refId!;
if (data.error) {
return of({ ...data, tableResult: null });
}
if (data.tableFrames.length === 0) { if (frameARefId > frameBRefId) {
return of({ ...data, tableResult: null }); return 1;
}
if (frameARefId < frameBRefId) {
return -1;
}
return 0;
});
const hasOnlyTimeseries = data.tableFrames.every(df => isTimeSeries(df));
// If we have only timeseries we do join on default time column which makes more sense. If we are showing
// non timeseries or some mix of data we are not trying to join on anything and just try to merge them in
// single table, which may not make sense in most cases, but it's up to the user to query something sensible.
const transformer = hasOnlyTimeseries
? of(data.tableFrames).pipe(standardTransformers.seriesToColumnsTransformer.operator({}))
: of(data.tableFrames).pipe(standardTransformers.mergeTransformer.operator({}));
return transformer.pipe(
map(frames => {
const frame = frames[0];
// set display processor
for (const field of frame.fields) {
field.display =
field.display ??
getDisplayProcessor({
field,
theme: config.theme,
timeZone: data.request?.timezone ?? 'browser',
});
} }
data.tableFrames.sort((frameA: DataFrame, frameB: DataFrame) => { return { ...data, tableResult: frame };
const frameARefId = frameA.refId!;
const frameBRefId = frameB.refId!;
if (frameARefId > frameBRefId) {
return 1;
}
if (frameARefId < frameBRefId) {
return -1;
}
return 0;
});
const hasOnlyTimeseries = data.tableFrames.every(df => isTimeSeries(df));
// If we have only timeseries we do join on default time column which makes more sense. If we are showing
// non timeseries or some mix of data we are not trying to join on anything and just try to merge them in
// single table, which may not make sense in most cases, but it's up to the user to query something sensible.
const transformer = hasOnlyTimeseries
? of(data.tableFrames).pipe(standardTransformers.seriesToColumnsTransformer.operator({}))
: of(data.tableFrames).pipe(standardTransformers.mergeTransformer.operator({}));
return transformer.pipe(
map(frames => {
const frame = frames[0];
// set display processor
for (const field of frame.fields) {
field.display =
field.display ??
getDisplayProcessor({
field,
theme: config.theme,
timeZone: data.request?.timezone ?? 'browser',
});
}
return { ...data, tableResult: frame };
})
);
}) })
); );
};
export const decorateWithLogsResult = ( export const decorateWithLogsResult = (
state: ExploreItemState options: { absoluteRange?: AbsoluteTimeRange; refreshInterval?: string } = {}
): MonoTypeOperatorFunction<ExplorePanelData> => inputStream => ) => (data: ExplorePanelData): ExplorePanelData => {
inputStream.pipe( if (data.error) {
map(data => { return { ...data, logsResult: null };
if (data.error) {
return { ...data, logsResult: null };
}
const { absoluteRange, refreshInterval } = state;
if (data.logsFrames.length === 0) {
return { ...data, logsResult: null };
}
const timeZone = data.request?.timezone ?? 'browser';
const intervalMs = data.request?.intervalMs;
const newResults = dataFrameToLogsModel(data.logsFrames, intervalMs, timeZone, absoluteRange);
const sortOrder = refreshIntervalToSortOrder(refreshInterval);
const sortedNewResults = sortLogsResult(newResults, sortOrder);
const rows = sortedNewResults.rows;
const series = sortedNewResults.series;
const logsResult = { ...sortedNewResults, rows, series };
return { ...data, logsResult };
})
);
function isTimeSeries(frame: DataFrame, datasource?: string): boolean {
// TEMP: Temporary hack. Remove when logs/metrics unification is done
if (datasource && datasource === 'cloudwatch') {
return isTimeSeriesCloudWatch(frame);
} }
if (frame.fields.length === 2) { if (data.logsFrames.length === 0) {
if (frame.fields[0].type === FieldType.time) { return { ...data, logsResult: null };
return true;
}
}
return false;
}
function shouldShowInVisualisationType(frame: DataFrame, visualisation: PreferredVisualisationType) {
if (frame.meta?.preferredVisualisationType && frame.meta?.preferredVisualisationType !== visualisation) {
return false;
} }
return true; const timeZone = data.request?.timezone ?? 'browser';
} const intervalMs = data.request?.intervalMs;
const newResults = dataFrameToLogsModel(data.logsFrames, intervalMs, timeZone, options.absoluteRange);
function shouldShowInVisualisationTypeStrict(frame: DataFrame, visualisation: PreferredVisualisationType) { const sortOrder = refreshIntervalToSortOrder(options.refreshInterval);
return frame.meta?.preferredVisualisationType === visualisation; const sortedNewResults = sortLogsResult(newResults, sortOrder);
} const rows = sortedNewResults.rows;
const series = sortedNewResults.series;
// TEMP: Temporary hack. Remove when logs/metrics unification is done const logsResult = { ...sortedNewResults, rows, series };
function isTimeSeriesCloudWatch(frame: DataFrame): boolean {
return ( return { ...data, logsResult };
frame.fields.some(field => field.type === FieldType.time) && };
frame.fields.some(field => field.type === FieldType.number)
/**
* Check if frame contains time series, which for our purpose means 1 time column and 1 or more numeric columns.
*/
function isTimeSeries(frame: DataFrame): boolean {
const grouped = groupBy(frame.fields, field => field.type);
return Boolean(
Object.keys(grouped).length === 2 && grouped[FieldType.time]?.length === 1 && grouped[FieldType.number]
); );
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment