mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Explore: Refactor & centralize URL/state sync (#66286)
Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>
This commit is contained in:
@@ -1675,10 +1675,7 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "7"]
|
|
||||||
],
|
],
|
||||||
"public/app/core/utils/fetch.ts:5381": [
|
"public/app/core/utils/fetch.ts:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||||
@@ -2560,6 +2557,9 @@ exports[`better eslint`] = {
|
|||||||
"public/app/features/explore/ElapsedTime.tsx:5381": [
|
"public/app/features/explore/ElapsedTime.tsx:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||||
],
|
],
|
||||||
|
"public/app/features/explore/ExplorePage.tsx:5381": [
|
||||||
|
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||||
|
],
|
||||||
"public/app/features/explore/ExploreQueryInspector.tsx:5381": [
|
"public/app/features/explore/ExploreQueryInspector.tsx:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||||
],
|
],
|
||||||
@@ -2620,14 +2620,14 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||||
],
|
],
|
||||||
|
"public/app/features/explore/hooks/useStateSync.ts:5381": [
|
||||||
|
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||||
|
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||||
|
[0, 0, 0, "Do not use any type assertions.", "2"]
|
||||||
|
],
|
||||||
"public/app/features/explore/spec/helper/setup.tsx:5381": [
|
"public/app/features/explore/spec/helper/setup.tsx:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
|
|
||||||
[0, 0, 0, "Do not use any type assertions.", "5"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
|
|
||||||
],
|
],
|
||||||
"public/app/features/explore/spec/interpolation.test.tsx:5381": [
|
"public/app/features/explore/spec/interpolation.test.tsx:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||||
@@ -2635,19 +2635,11 @@ exports[`better eslint`] = {
|
|||||||
"public/app/features/explore/spec/queryHistory.test.tsx:5381": [
|
"public/app/features/explore/spec/queryHistory.test.tsx:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||||
],
|
],
|
||||||
"public/app/features/explore/state/explorePane.test.ts:5381": [
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
|
|
||||||
],
|
|
||||||
"public/app/features/explore/state/history.ts:5381": [
|
"public/app/features/explore/state/history.ts:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||||
],
|
],
|
||||||
"public/app/features/explore/state/main.ts:5381": [
|
"public/app/features/explore/state/main.ts:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
|
||||||
],
|
],
|
||||||
"public/app/features/explore/state/query.ts:5381": [
|
"public/app/features/explore/state/query.ts:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||||
@@ -5773,7 +5765,8 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "8"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "8"]
|
||||||
],
|
],
|
||||||
"public/app/types/store.ts:5381": [
|
"public/app/types/store.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||||
|
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||||
],
|
],
|
||||||
"public/app/types/templates.ts:5381": [
|
"public/app/types/templates.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||||
|
@@ -16,6 +16,7 @@ e2e.scenario({
|
|||||||
cy.get('button[title="Delete query"]').each((button) => {
|
cy.get('button[title="Delete query"]').each((button) => {
|
||||||
button.trigger('click');
|
button.trigger('click');
|
||||||
});
|
});
|
||||||
|
cy.get('button[title="Delete query"]').should('not.exist');
|
||||||
e2e.components.QueryTab.queryHistoryButton().should('be.visible').click();
|
e2e.components.QueryTab.queryHistoryButton().should('be.visible').click();
|
||||||
|
|
||||||
e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer()
|
e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer()
|
||||||
@@ -36,7 +37,7 @@ e2e.scenario({
|
|||||||
cy.get('body').click();
|
cy.get('body').click();
|
||||||
cy.get('body').type('t{leftarrow}');
|
cy.get('body').type('t{leftarrow}');
|
||||||
|
|
||||||
cy.location().then((locPostKeypress) => {
|
cy.location().should((locPostKeypress) => {
|
||||||
const params = new URLSearchParams(locPostKeypress.search);
|
const params = new URLSearchParams(locPostKeypress.search);
|
||||||
const leftJSON = JSON.parse(params.get('left'));
|
const leftJSON = JSON.parse(params.get('left'));
|
||||||
// be sure the keypress affected the time window
|
// be sure the keypress affected the time window
|
||||||
|
@@ -11,7 +11,6 @@ export interface ExploreUrlState<T extends DataQuery = AnyQuery> {
|
|||||||
range: RawTimeRange;
|
range: RawTimeRange;
|
||||||
context?: string;
|
context?: string;
|
||||||
panelsState?: ExplorePanelsState;
|
panelsState?: ExplorePanelsState;
|
||||||
isFromCompactUrl?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExplorePanelsState extends Partial<Record<PreferredVisualisationType, {}>> {
|
export interface ExplorePanelsState extends Partial<Record<PreferredVisualisationType, {}>> {
|
||||||
|
@@ -8,17 +8,13 @@ import { DatasourceSrvMock, MockDataSourceApi } from '../../../test/mocks/dataso
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildQueryTransaction,
|
buildQueryTransaction,
|
||||||
clearHistory,
|
|
||||||
DEFAULT_RANGE,
|
DEFAULT_RANGE,
|
||||||
getRefIds,
|
|
||||||
getValueWithRefId,
|
|
||||||
hasNonEmptyQuery,
|
hasNonEmptyQuery,
|
||||||
parseUrlState,
|
parseUrlState,
|
||||||
refreshIntervalToSortOrder,
|
refreshIntervalToSortOrder,
|
||||||
updateHistory,
|
updateHistory,
|
||||||
getExploreUrl,
|
getExploreUrl,
|
||||||
GetExploreUrlArguments,
|
GetExploreUrlArguments,
|
||||||
getTimeRangeFromUrl,
|
|
||||||
getTimeRange,
|
getTimeRange,
|
||||||
generateEmptyQuery,
|
generateEmptyQuery,
|
||||||
} from './explore';
|
} from './explore';
|
||||||
@@ -140,7 +136,6 @@ describe('state functions', () => {
|
|||||||
const state = {
|
const state = {
|
||||||
...DEFAULT_EXPLORE_STATE,
|
...DEFAULT_EXPLORE_STATE,
|
||||||
datasource: 'foo',
|
datasource: 'foo',
|
||||||
isFromCompactUrl: false,
|
|
||||||
queries: [
|
queries: [
|
||||||
{
|
{
|
||||||
expr: 'metric{test="a/b"}',
|
expr: 'metric{test="a/b"}',
|
||||||
@@ -165,7 +160,6 @@ describe('state functions', () => {
|
|||||||
const state = {
|
const state = {
|
||||||
...DEFAULT_EXPLORE_STATE,
|
...DEFAULT_EXPLORE_STATE,
|
||||||
datasource: 'foo',
|
datasource: 'foo',
|
||||||
isFromCompactUrl: false,
|
|
||||||
queries: [
|
queries: [
|
||||||
{
|
{
|
||||||
expr: 'metric{test="a/b"}',
|
expr: 'metric{test="a/b"}',
|
||||||
@@ -228,7 +222,7 @@ describe('updateHistory()', () => {
|
|||||||
const key = `grafana.explore.history.${datasourceId}`;
|
const key = `grafana.explore.history.${datasourceId}`;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clearHistory(datasourceId);
|
store.delete(key);
|
||||||
expect(store.exists(key)).toBeFalsy();
|
expect(store.exists(key)).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,87 +252,6 @@ describe('hasNonEmptyQuery', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('hasRefId', () => {
|
|
||||||
describe('when called with a null value', () => {
|
|
||||||
it('then it should return undefined', () => {
|
|
||||||
const input = null;
|
|
||||||
const result = getValueWithRefId(input);
|
|
||||||
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when called with a non object value', () => {
|
|
||||||
it('then it should return undefined', () => {
|
|
||||||
const input = 123;
|
|
||||||
const result = getValueWithRefId(input);
|
|
||||||
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when called with an object that has refId', () => {
|
|
||||||
it('then it should return the object', () => {
|
|
||||||
const input = { refId: 'A' };
|
|
||||||
const result = getValueWithRefId(input);
|
|
||||||
|
|
||||||
expect(result).toBe(input);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when called with an array that has refId', () => {
|
|
||||||
it('then it should return the object', () => {
|
|
||||||
const input = [123, null, {}, { refId: 'A' }];
|
|
||||||
const result = getValueWithRefId(input);
|
|
||||||
|
|
||||||
expect(result).toBe(input[3]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when called with an object that has refId somewhere in the object tree', () => {
|
|
||||||
it('then it should return the object', () => {
|
|
||||||
const mockObject = { refId: 'A' };
|
|
||||||
const input = { data: [123, null, {}, { series: [123, null, {}, mockObject] }] };
|
|
||||||
const result = getValueWithRefId(input);
|
|
||||||
|
|
||||||
expect(result).toBe(mockObject);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getTimeRangeFromUrl', () => {
|
|
||||||
it('should parse moment date', () => {
|
|
||||||
// convert date strings to moment object
|
|
||||||
const range = { from: dateTime('2020-10-22T10:44:33.615Z'), to: dateTime('2020-10-22T10:49:33.615Z') };
|
|
||||||
const result = getTimeRangeFromUrl(range, 'browser', 0);
|
|
||||||
expect(result.raw).toEqual(range);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse epoch strings', () => {
|
|
||||||
const range = {
|
|
||||||
from: dateTime('2020-10-22T10:00:00Z').valueOf().toString(),
|
|
||||||
to: dateTime('2020-10-22T11:00:00Z').valueOf().toString(),
|
|
||||||
};
|
|
||||||
const result = getTimeRangeFromUrl(range, 'browser', 0);
|
|
||||||
expect(result.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf());
|
|
||||||
expect(result.to.valueOf()).toEqual(dateTime('2020-10-22T11:00:00Z').valueOf());
|
|
||||||
expect(result.raw.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf());
|
|
||||||
expect(result.raw.to.valueOf()).toEqual(dateTime('2020-10-22T11:00:00Z').valueOf());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse ISO strings', () => {
|
|
||||||
const range = {
|
|
||||||
from: dateTime('2020-10-22T10:00:00Z').toISOString(),
|
|
||||||
to: dateTime('2020-10-22T11:00:00Z').toISOString(),
|
|
||||||
};
|
|
||||||
const result = getTimeRangeFromUrl(range, 'browser', 0);
|
|
||||||
expect(result.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf());
|
|
||||||
expect(result.to.valueOf()).toEqual(dateTime('2020-10-22T11:00:00Z').valueOf());
|
|
||||||
expect(result.raw.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf());
|
|
||||||
expect(result.raw.to.valueOf()).toEqual(dateTime('2020-10-22T11:00:00Z').valueOf());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getTimeRange', () => {
|
describe('getTimeRange', () => {
|
||||||
describe('should flip from and to when from is after to', () => {
|
describe('should flip from and to when from is after to', () => {
|
||||||
const rawRange = {
|
const rawRange = {
|
||||||
@@ -352,62 +265,6 @@ describe('getTimeRange', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getRefIds', () => {
|
|
||||||
describe('when called with a null value', () => {
|
|
||||||
it('then it should return empty array', () => {
|
|
||||||
const input = null;
|
|
||||||
const result = getRefIds(input);
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when called with a non object value', () => {
|
|
||||||
it('then it should return empty array', () => {
|
|
||||||
const input = 123;
|
|
||||||
const result = getRefIds(input);
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when called with an object that has refId', () => {
|
|
||||||
it('then it should return an array with that refId', () => {
|
|
||||||
const input = { refId: 'A' };
|
|
||||||
const result = getRefIds(input);
|
|
||||||
|
|
||||||
expect(result).toEqual(['A']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when called with an array that has refIds', () => {
|
|
||||||
it('then it should return an array with unique refIds', () => {
|
|
||||||
const input = [123, null, {}, { refId: 'A' }, { refId: 'A' }, { refId: 'B' }];
|
|
||||||
const result = getRefIds(input);
|
|
||||||
|
|
||||||
expect(result).toEqual(['A', 'B']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when called with an object that has refIds somewhere in the object tree', () => {
|
|
||||||
it('then it should return return an array with unique refIds', () => {
|
|
||||||
const input = {
|
|
||||||
data: [
|
|
||||||
123,
|
|
||||||
null,
|
|
||||||
{ refId: 'B', series: [{ refId: 'X' }] },
|
|
||||||
{ refId: 'B' },
|
|
||||||
{},
|
|
||||||
{ series: [123, null, {}, { refId: 'A' }] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const result = getRefIds(input);
|
|
||||||
|
|
||||||
expect(result).toEqual(['B', 'X', 'A']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('refreshIntervalToSortOrder', () => {
|
describe('refreshIntervalToSortOrder', () => {
|
||||||
describe('when called with live option', () => {
|
describe('when called with live option', () => {
|
||||||
it('then it should return ascending', () => {
|
it('then it should return ascending', () => {
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { flatten, omit, uniq } from 'lodash';
|
import { omit } from 'lodash';
|
||||||
import { Unsubscribable } from 'rxjs';
|
import { Unsubscribable } from 'rxjs';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
@@ -8,21 +8,16 @@ import {
|
|||||||
DataQueryRequest,
|
DataQueryRequest,
|
||||||
DataSourceApi,
|
DataSourceApi,
|
||||||
DataSourceRef,
|
DataSourceRef,
|
||||||
dateMath,
|
|
||||||
DateTime,
|
|
||||||
DefaultTimeZone,
|
DefaultTimeZone,
|
||||||
ExploreUrlState,
|
ExploreUrlState,
|
||||||
HistoryItem,
|
HistoryItem,
|
||||||
IntervalValues,
|
IntervalValues,
|
||||||
isDateTime,
|
|
||||||
LogsDedupStrategy,
|
LogsDedupStrategy,
|
||||||
LogsSortOrder,
|
LogsSortOrder,
|
||||||
rangeUtil,
|
rangeUtil,
|
||||||
RawTimeRange,
|
RawTimeRange,
|
||||||
TimeFragment,
|
|
||||||
TimeRange,
|
TimeRange,
|
||||||
TimeZone,
|
TimeZone,
|
||||||
toUtc,
|
|
||||||
urlUtil,
|
urlUtil,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { DataSourceSrv, getDataSourceSrv } from '@grafana/runtime';
|
import { DataSourceSrv, getDataSourceSrv } from '@grafana/runtime';
|
||||||
@@ -48,8 +43,12 @@ export const DEFAULT_UI_STATE = {
|
|||||||
|
|
||||||
const MAX_HISTORY_ITEMS = 100;
|
const MAX_HISTORY_ITEMS = 100;
|
||||||
|
|
||||||
export const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
|
const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
|
||||||
export const lastUsedDatasourceKeyForOrgId = (orgId: number) => `${LAST_USED_DATASOURCE_KEY}.${orgId}`;
|
const lastUsedDatasourceKeyForOrgId = (orgId: number) => `${LAST_USED_DATASOURCE_KEY}.${orgId}`;
|
||||||
|
export const getLastUsedDatasourceUID = (orgId: number) =>
|
||||||
|
store.getObject<string>(lastUsedDatasourceKeyForOrgId(orgId));
|
||||||
|
export const setLastUsedDatasourceUID = (orgId: number, datasourceUID: string) =>
|
||||||
|
store.setObject(lastUsedDatasourceKeyForOrgId(orgId), datasourceUID);
|
||||||
|
|
||||||
export interface GetExploreUrlArguments {
|
export interface GetExploreUrlArguments {
|
||||||
panel: PanelModel;
|
panel: PanelModel;
|
||||||
@@ -223,8 +222,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(parsed)) {
|
if (!Array.isArray(parsed)) {
|
||||||
const urlState = { ...parsed, isFromCompactUrl: false };
|
return { queries: [], range: DEFAULT_RANGE, ...parsed };
|
||||||
return urlState;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.length <= ParseUrlStateIndex.SegmentsStart) {
|
if (parsed.length <= ParseUrlStateIndex.SegmentsStart) {
|
||||||
@@ -241,7 +239,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
|
|||||||
const queries = parsedSegments.filter((segment) => !isSegment(segment, 'ui', 'mode', '__panelsState'));
|
const queries = parsedSegments.filter((segment) => !isSegment(segment, 'ui', 'mode', '__panelsState'));
|
||||||
|
|
||||||
const panelsState = parsedSegments.find((segment) => isSegment(segment, '__panelsState'))?.__panelsState;
|
const panelsState = parsedSegments.find((segment) => isSegment(segment, '__panelsState'))?.__panelsState;
|
||||||
return { datasource, queries, range, panelsState, isFromCompactUrl: true };
|
return { datasource, queries, range, panelsState };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateKey(index = 0): string {
|
export function generateKey(index = 0): string {
|
||||||
@@ -285,15 +283,6 @@ export const generateNewKeyAndAddRefIdIfMissing = (target: DataQuery, queries: D
|
|||||||
return { ...target, refId, key };
|
return { ...target, refId, key };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const queryDatasourceDetails = (queries: DataQuery[]) => {
|
|
||||||
const allUIDs = queries.map((query) => query.datasource?.uid);
|
|
||||||
return {
|
|
||||||
allHaveDatasource: allUIDs.length === queries.length,
|
|
||||||
noneHaveDatasource: allUIDs.length === 0,
|
|
||||||
allDatasourceSame: allUIDs.every((val, i, arr) => val === arr[0]),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure at least one target exists and that targets have the necessary keys
|
* Ensure at least one target exists and that targets have the necessary keys
|
||||||
*
|
*
|
||||||
@@ -396,11 +385,6 @@ export function updateHistory<T extends DataQuery>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearHistory(datasourceId: string) {
|
|
||||||
const historyKey = `grafana.explore.history.${datasourceId}`;
|
|
||||||
store.delete(historyKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getQueryKeys = (queries: DataQuery[]): string[] => {
|
export const getQueryKeys = (queries: DataQuery[]): string[] => {
|
||||||
const queryKeys = queries.reduce<string[]>((newQueryKeys, query, index) => {
|
const queryKeys = queries.reduce<string[]>((newQueryKeys, query, index) => {
|
||||||
const primaryKey = query.datasource?.uid || query.key;
|
const primaryKey = query.datasource?.uid || query.key;
|
||||||
@@ -420,105 +404,6 @@ export const getTimeRange = (timeZone: TimeZone, rawRange: RawTimeRange, fiscalY
|
|||||||
return range;
|
return range;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseRawTime = (value: string | DateTime): TimeFragment | null => {
|
|
||||||
if (value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDateTime(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.indexOf('now') !== -1) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (value.length === 8) {
|
|
||||||
return toUtc(value, 'YYYYMMDD');
|
|
||||||
}
|
|
||||||
if (value.length === 15) {
|
|
||||||
return toUtc(value, 'YYYYMMDDTHHmmss');
|
|
||||||
}
|
|
||||||
// Backward compatibility
|
|
||||||
if (value.length === 19) {
|
|
||||||
return toUtc(value, 'YYYY-MM-DD HH:mm:ss');
|
|
||||||
}
|
|
||||||
|
|
||||||
// This should handle cases where value is an epoch time as string
|
|
||||||
if (value.match(/^\d+$/)) {
|
|
||||||
const epoch = parseInt(value, 10);
|
|
||||||
return toUtc(epoch);
|
|
||||||
}
|
|
||||||
|
|
||||||
// This should handle ISO strings
|
|
||||||
const time = toUtc(value);
|
|
||||||
if (time.isValid()) {
|
|
||||||
return time;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getTimeRangeFromUrl = (
|
|
||||||
range: RawTimeRange,
|
|
||||||
timeZone: TimeZone,
|
|
||||||
fiscalYearStartMonth: number
|
|
||||||
): TimeRange => {
|
|
||||||
const raw = {
|
|
||||||
from: parseRawTime(range.from)!,
|
|
||||||
to: parseRawTime(range.to)!,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
from: dateMath.parse(raw.from, false, timeZone)!,
|
|
||||||
to: dateMath.parse(raw.to, true, timeZone)!,
|
|
||||||
raw,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getValueWithRefId = (value?: any): any => {
|
|
||||||
if (!value || typeof value !== 'object') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.refId) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = Object.keys(value);
|
|
||||||
for (let index = 0; index < keys.length; index++) {
|
|
||||||
const key = keys[index];
|
|
||||||
const refId = getValueWithRefId(value[key]);
|
|
||||||
if (refId) {
|
|
||||||
return refId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRefIds = (value: any): string[] => {
|
|
||||||
if (!value) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value !== 'object') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = Object.keys(value);
|
|
||||||
const refIds = [];
|
|
||||||
for (let index = 0; index < keys.length; index++) {
|
|
||||||
const key = keys[index];
|
|
||||||
if (key === 'refId') {
|
|
||||||
refIds.push(value[key]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
refIds.push(getRefIds(value[key]));
|
|
||||||
}
|
|
||||||
|
|
||||||
return uniq(flatten(refIds));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const refreshIntervalToSortOrder = (refreshInterval?: string) =>
|
export const refreshIntervalToSortOrder = (refreshInterval?: string) =>
|
||||||
RefreshPicker.isLive(refreshInterval) ? LogsSortOrder.Ascending : LogsSortOrder.Descending;
|
RefreshPicker.isLive(refreshInterval) ? LogsSortOrder.Ascending : LogsSortOrder.Descending;
|
||||||
|
|
||||||
|
@@ -5,11 +5,13 @@ import { TestProvider } from 'test/helpers/TestProvider';
|
|||||||
|
|
||||||
import { DataSourceApi, LoadingState, CoreApp, createTheme, EventBusSrv } from '@grafana/data';
|
import { DataSourceApi, LoadingState, CoreApp, createTheme, EventBusSrv } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
import { configureStore } from 'app/store/configureStore';
|
||||||
import { ExploreId } from 'app/types';
|
import { ExploreId } from 'app/types';
|
||||||
|
|
||||||
import { Explore, Props } from './Explore';
|
import { Explore, Props } from './Explore';
|
||||||
|
import { initialExploreState } from './state/main';
|
||||||
import { scanStopAction } from './state/query';
|
import { scanStopAction } from './state/query';
|
||||||
import { createEmptyQueryResponse } from './state/utils';
|
import { createEmptyQueryResponse, makeExplorePaneState } from './state/utils';
|
||||||
|
|
||||||
const resizeWindow = (x: number, y: number) => {
|
const resizeWindow = (x: number, y: number) => {
|
||||||
global.innerWidth = x;
|
global.innerWidth = x;
|
||||||
@@ -59,7 +61,6 @@ const dummyProps: Props = {
|
|||||||
QueryEditorHelp: {},
|
QueryEditorHelp: {},
|
||||||
},
|
},
|
||||||
} as DataSourceApi,
|
} as DataSourceApi,
|
||||||
datasourceMissing: false,
|
|
||||||
exploreId: ExploreId.left,
|
exploreId: ExploreId.left,
|
||||||
loading: false,
|
loading: false,
|
||||||
modifyQueries: jest.fn(),
|
modifyQueries: jest.fn(),
|
||||||
@@ -89,7 +90,6 @@ const dummyProps: Props = {
|
|||||||
showFlameGraph: true,
|
showFlameGraph: true,
|
||||||
splitOpen: jest.fn(),
|
splitOpen: jest.fn(),
|
||||||
splitted: false,
|
splitted: false,
|
||||||
isFromCompactUrl: false,
|
|
||||||
eventBus: new EventBusSrv(),
|
eventBus: new EventBusSrv(),
|
||||||
showRawPrometheus: false,
|
showRawPrometheus: false,
|
||||||
showLogsSample: false,
|
showLogsSample: false,
|
||||||
@@ -119,10 +119,18 @@ jest.mock('react-virtualized-auto-sizer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const setup = (overrideProps?: Partial<Props>) => {
|
const setup = (overrideProps?: Partial<Props>) => {
|
||||||
|
const store = configureStore({
|
||||||
|
explore: {
|
||||||
|
...initialExploreState,
|
||||||
|
panes: {
|
||||||
|
left: makeExplorePaneState(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
const exploreProps = { ...dummyProps, ...overrideProps };
|
const exploreProps = { ...dummyProps, ...overrideProps };
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
<TestProvider>
|
<TestProvider store={store}>
|
||||||
<Explore {...exploreProps} />
|
<Explore {...exploreProps} />
|
||||||
</TestProvider>
|
</TestProvider>
|
||||||
);
|
);
|
||||||
@@ -139,8 +147,7 @@ describe('Explore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render no data with done loading state', async () => {
|
it('should render no data with done loading state', async () => {
|
||||||
const queryResp = makeEmptyQueryResponse(LoadingState.Done);
|
setup({ queryResponse: makeEmptyQueryResponse(LoadingState.Done) });
|
||||||
setup({ queryResponse: queryResp });
|
|
||||||
|
|
||||||
// Wait for the Explore component to render
|
// Wait for the Explore component to render
|
||||||
await screen.findByTestId(selectors.components.DataSourcePicker.container);
|
await screen.findByTestId(selectors.components.DataSourcePicker.container);
|
||||||
|
@@ -25,18 +25,16 @@ import {
|
|||||||
Themeable2,
|
Themeable2,
|
||||||
withTheme2,
|
withTheme2,
|
||||||
PanelContainer,
|
PanelContainer,
|
||||||
Alert,
|
|
||||||
AdHocFilterItem,
|
AdHocFilterItem,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/src/components/Table/types';
|
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/src/components/Table/types';
|
||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
import { FadeIn } from 'app/core/components/Animations/FadeIn';
|
|
||||||
import { supportedFeatures } from 'app/core/history/richHistoryStorageProvider';
|
import { supportedFeatures } from 'app/core/history/richHistoryStorageProvider';
|
||||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||||
import { getNodeGraphDataFrames } from 'app/plugins/panel/nodeGraph/utils';
|
import { getNodeGraphDataFrames } from 'app/plugins/panel/nodeGraph/utils';
|
||||||
import { StoreState } from 'app/types';
|
import { StoreState } from 'app/types';
|
||||||
import { AbsoluteTimeEvent } from 'app/types/events';
|
import { AbsoluteTimeEvent } from 'app/types/events';
|
||||||
import { ExploreId, ExploreItemState } from 'app/types/explore';
|
import { ExploreId } from 'app/types/explore';
|
||||||
|
|
||||||
import { getTimeZone } from '../profile/state/selectors';
|
import { getTimeZone } from '../profile/state/selectors';
|
||||||
|
|
||||||
@@ -285,17 +283,6 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
|||||||
return <NoData />;
|
return <NoData />;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCompactUrlWarning() {
|
|
||||||
return (
|
|
||||||
<FadeIn in={true} duration={100}>
|
|
||||||
<Alert severity="warning" title="Compact URL Deprecation Notice" topSpacing={2}>
|
|
||||||
The URL that brought you here was a compact URL - this format will soon be deprecated. Please replace the URL
|
|
||||||
previously saved with the URL available now.
|
|
||||||
</Alert>
|
|
||||||
</FadeIn>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderGraphPanel(width: number) {
|
renderGraphPanel(width: number) {
|
||||||
const { graphResult, absoluteRange, timeZone, queryResponse, loading, showFlameGraph } = this.props;
|
const { graphResult, absoluteRange, timeZone, queryResponse, loading, showFlameGraph } = this.props;
|
||||||
|
|
||||||
@@ -426,7 +413,6 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
|||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
datasourceInstance,
|
datasourceInstance,
|
||||||
datasourceMissing,
|
|
||||||
exploreId,
|
exploreId,
|
||||||
graphResult,
|
graphResult,
|
||||||
queryResponse,
|
queryResponse,
|
||||||
@@ -440,7 +426,6 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
|||||||
showNodeGraph,
|
showNodeGraph,
|
||||||
showFlameGraph,
|
showFlameGraph,
|
||||||
timeZone,
|
timeZone,
|
||||||
isFromCompactUrl,
|
|
||||||
showLogsSample,
|
showLogsSample,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { openDrawer } = this.state;
|
const { openDrawer } = this.state;
|
||||||
@@ -468,9 +453,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
|||||||
scrollRefCallback={(scrollElement) => (this.scrollElement = scrollElement || undefined)}
|
scrollRefCallback={(scrollElement) => (this.scrollElement = scrollElement || undefined)}
|
||||||
>
|
>
|
||||||
<ExploreToolbar exploreId={exploreId} onChangeTime={this.onChangeTime} topOfViewRef={this.topOfViewRef} />
|
<ExploreToolbar exploreId={exploreId} onChangeTime={this.onChangeTime} topOfViewRef={this.topOfViewRef} />
|
||||||
{isFromCompactUrl ? this.renderCompactUrlWarning() : null}
|
{datasourceInstance ? (
|
||||||
{datasourceMissing ? this.renderEmptyState(styles.exploreContainer) : null}
|
|
||||||
{datasourceInstance && (
|
|
||||||
<div className={styles.exploreContainer}>
|
<div className={styles.exploreContainer}>
|
||||||
<PanelContainer className={styles.queryContainer}>
|
<PanelContainer className={styles.queryContainer}>
|
||||||
<QueryRows exploreId={exploreId} />
|
<QueryRows exploreId={exploreId} />
|
||||||
@@ -537,6 +520,8 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
|||||||
}}
|
}}
|
||||||
</AutoSizer>
|
</AutoSizer>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
this.renderEmptyState(styles.exploreContainer)
|
||||||
)}
|
)}
|
||||||
</CustomScrollbar>
|
</CustomScrollbar>
|
||||||
);
|
);
|
||||||
@@ -546,11 +531,11 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
|||||||
function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
||||||
const explore = state.explore;
|
const explore = state.explore;
|
||||||
const { syncedTimes } = explore;
|
const { syncedTimes } = explore;
|
||||||
const item: ExploreItemState = explore.panes[exploreId]!;
|
const item = explore.panes[exploreId]!;
|
||||||
|
|
||||||
const timeZone = getTimeZone(state.user);
|
const timeZone = getTimeZone(state.user);
|
||||||
const {
|
const {
|
||||||
datasourceInstance,
|
datasourceInstance,
|
||||||
datasourceMissing,
|
|
||||||
queryKeys,
|
queryKeys,
|
||||||
queries,
|
queries,
|
||||||
isLive,
|
isLive,
|
||||||
@@ -566,7 +551,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
|||||||
showNodeGraph,
|
showNodeGraph,
|
||||||
showFlameGraph,
|
showFlameGraph,
|
||||||
loading,
|
loading,
|
||||||
isFromCompactUrl,
|
|
||||||
showRawPrometheus,
|
showRawPrometheus,
|
||||||
supplementaryQueries,
|
supplementaryQueries,
|
||||||
} = item;
|
} = item;
|
||||||
@@ -577,7 +561,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
datasourceInstance,
|
datasourceInstance,
|
||||||
datasourceMissing,
|
|
||||||
queryKeys,
|
queryKeys,
|
||||||
queries,
|
queries,
|
||||||
isLive,
|
isLive,
|
||||||
@@ -596,7 +579,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
|||||||
showFlameGraph,
|
showFlameGraph,
|
||||||
splitted: isSplit(state),
|
splitted: isSplit(state),
|
||||||
loading,
|
loading,
|
||||||
isFromCompactUrl: isFromCompactUrl || false,
|
|
||||||
logsSample,
|
logsSample,
|
||||||
showLogsSample,
|
showLogsSample,
|
||||||
};
|
};
|
||||||
|
@@ -32,7 +32,7 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => {
|
|||||||
name: 'Run query (left)',
|
name: 'Run query (left)',
|
||||||
keywords: 'query left',
|
keywords: 'query left',
|
||||||
perform: () => {
|
perform: () => {
|
||||||
dispatch(runQueries(exploreIdLeft));
|
dispatch(runQueries({ exploreId: exploreIdLeft }));
|
||||||
},
|
},
|
||||||
section: exploreSection,
|
section: exploreSection,
|
||||||
});
|
});
|
||||||
@@ -43,7 +43,7 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => {
|
|||||||
name: 'Run query (right)',
|
name: 'Run query (right)',
|
||||||
keywords: 'query right',
|
keywords: 'query right',
|
||||||
perform: () => {
|
perform: () => {
|
||||||
dispatch(runQueries(exploreIdRight));
|
dispatch(runQueries({ exploreId: exploreIdRight }));
|
||||||
},
|
},
|
||||||
section: exploreSection,
|
section: exploreSection,
|
||||||
});
|
});
|
||||||
@@ -72,7 +72,7 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => {
|
|||||||
name: 'Run query',
|
name: 'Run query',
|
||||||
keywords: 'query',
|
keywords: 'query',
|
||||||
perform: () => {
|
perform: () => {
|
||||||
dispatch(runQueries(exploreIdLeft));
|
dispatch(runQueries({ exploreId: exploreIdLeft }));
|
||||||
},
|
},
|
||||||
section: exploreSection,
|
section: exploreSection,
|
||||||
});
|
});
|
||||||
|
@@ -4,12 +4,12 @@ import React, { ComponentProps } from 'react';
|
|||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
|
|
||||||
import { serializeStateToUrlParam } from '@grafana/data';
|
import { serializeStateToUrlParam } from '@grafana/data';
|
||||||
import { locationService, config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
|
import { ExploreId } from 'app/types';
|
||||||
|
|
||||||
import { makeLogsQueryResponse } from './spec/helper/query';
|
import { makeLogsQueryResponse } from './spec/helper/query';
|
||||||
import { setupExplore, tearDown, waitForExplore } from './spec/helper/setup';
|
import { setupExplore, tearDown, waitForExplore } from './spec/helper/setup';
|
||||||
import * as mainState from './state/main';
|
import * as mainState from './state/main';
|
||||||
import * as queryState from './state/query';
|
|
||||||
|
|
||||||
jest.mock('app/core/core', () => {
|
jest.mock('app/core/core', () => {
|
||||||
return {
|
return {
|
||||||
@@ -40,13 +40,49 @@ describe('ExplorePage', () => {
|
|||||||
|
|
||||||
describe('Handles open/close splits and related events in UI and URL', () => {
|
describe('Handles open/close splits and related events in UI and URL', () => {
|
||||||
it('opens the split pane when split button is clicked', async () => {
|
it('opens the split pane when split button is clicked', async () => {
|
||||||
setupExplore();
|
const { location } = setupExplore();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const editors = screen.getAllByText('loki Editor input:');
|
||||||
|
expect(editors.length).toBe(1);
|
||||||
|
|
||||||
|
// initializing explore replaces the first history entry
|
||||||
|
expect(location.getHistory().length).toBe(1);
|
||||||
|
expect(location.getHistory().action).toBe('REPLACE');
|
||||||
|
});
|
||||||
|
|
||||||
// Wait for rendering the editor
|
// Wait for rendering the editor
|
||||||
const splitButton = await screen.findByText(/split/i);
|
const splitButton = await screen.findByRole('button', { name: /split/i });
|
||||||
await userEvent.click(splitButton);
|
await userEvent.click(splitButton);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const editors = screen.getAllByText('loki Editor input:');
|
const editors = screen.getAllByText('loki Editor input:');
|
||||||
expect(editors.length).toBe(2);
|
expect(editors.length).toBe(2);
|
||||||
|
// a new entry is pushed to the history
|
||||||
|
expect(location.getHistory().length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
location.getHistory().goBack();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const editors = screen.getAllByText('loki Editor input:');
|
||||||
|
expect(editors.length).toBe(1);
|
||||||
|
// going back pops the history
|
||||||
|
expect(location.getHistory().action).toBe('POP');
|
||||||
|
expect(location.getHistory().length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
location.getHistory().goForward();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const editors = screen.getAllByText('loki Editor input:');
|
||||||
|
expect(editors.length).toBe(2);
|
||||||
|
// going forward pops the history
|
||||||
|
expect(location.getHistory().action).toBe('POP');
|
||||||
|
expect(location.getHistory().length).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,7 +100,7 @@ describe('ExplorePage', () => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { datasources } = setupExplore({ urlParams });
|
const { datasources, location } = setupExplore({ urlParams });
|
||||||
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||||
jest.mocked(datasources.elastic.query).mockReturnValueOnce(makeLogsQueryResponse());
|
jest.mocked(datasources.elastic.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||||
|
|
||||||
@@ -79,14 +115,11 @@ describe('ExplorePage', () => {
|
|||||||
expect(logsLines.length).toBe(2);
|
expect(logsLines.length).toBe(2);
|
||||||
|
|
||||||
// And that the editor gets the expr from the url
|
// And that the editor gets the expr from the url
|
||||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
expect(screen.getByText(`loki Editor input: { label="value"}`)).toBeInTheDocument();
|
||||||
await screen.findByText(`elastic Editor input: error`);
|
expect(screen.getByText(`elastic Editor input: error`)).toBeInTheDocument();
|
||||||
|
|
||||||
// We did not change the url
|
// We did not change the url
|
||||||
expect(locationService.getSearchObject()).toEqual({
|
expect(location.getSearchObject()).toEqual(expect.objectContaining(urlParams));
|
||||||
orgId: '1',
|
|
||||||
...urlParams,
|
|
||||||
});
|
|
||||||
|
|
||||||
// We called the data source query method once
|
// We called the data source query method once
|
||||||
expect(datasources.loki.query).toBeCalledTimes(1);
|
expect(datasources.loki.query).toBeCalledTimes(1);
|
||||||
@@ -100,39 +133,50 @@ describe('ExplorePage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO: the following tests are using the compact format, we should use the current format instead
|
||||||
|
// and have a dedicated test ensuring the compact format is parsed correctly
|
||||||
it('can close a panel from a split', async () => {
|
it('can close a panel from a split', async () => {
|
||||||
const urlParams = {
|
const urlParams = {
|
||||||
left: JSON.stringify(['now-1h', 'now', 'loki', { refId: 'A' }]),
|
left: JSON.stringify(['now-1h', 'now', 'loki', { refId: 'A' }]),
|
||||||
right: JSON.stringify(['now-1h', 'now', 'elastic', { refId: 'A' }]),
|
right: JSON.stringify(['now-1h', 'now', 'elastic', { refId: 'A' }]),
|
||||||
};
|
};
|
||||||
setupExplore({ urlParams });
|
const { location } = setupExplore({ urlParams });
|
||||||
const closeButtons = await screen.findAllByLabelText(/Close split pane/i);
|
let closeButtons = await screen.findAllByLabelText(/Close split pane/i);
|
||||||
await userEvent.click(closeButtons[1]);
|
await userEvent.click(closeButtons[1]);
|
||||||
|
|
||||||
|
expect(location.getHistory().length).toBe(1);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const postCloseButtons = screen.queryAllByLabelText(/Close split pane/i);
|
closeButtons = screen.queryAllByLabelText(/Close split pane/i);
|
||||||
expect(postCloseButtons.length).toBe(0);
|
expect(closeButtons.length).toBe(0);
|
||||||
|
// Closing a pane using the split close button causes a new entry to be pushed in the history
|
||||||
|
expect(location.getHistory().length).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles url change to split view', async () => {
|
it('Reacts to URL changes and opens a pane if an entry is pushed to history', async () => {
|
||||||
const urlParams = {
|
const urlParams = {
|
||||||
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
|
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
|
||||||
};
|
};
|
||||||
const { datasources } = setupExplore({ urlParams });
|
const { datasources, location } = setupExplore({ urlParams });
|
||||||
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
|
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
|
||||||
jest.mocked(datasources.elastic.query).mockReturnValue(makeLogsQueryResponse());
|
jest.mocked(datasources.elastic.query).mockReturnValue(makeLogsQueryResponse());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(`loki Editor input: { label="value"}`)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
locationService.partial({
|
location.partial({
|
||||||
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
|
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
|
||||||
right: JSON.stringify(['now-1h', 'now', 'elastic', { expr: 'error' }]),
|
right: JSON.stringify(['now-1h', 'now', 'elastic', { expr: 'error' }]),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Editor renders the new query
|
await waitFor(() => {
|
||||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
expect(screen.getByText(`loki Editor input: { label="value"}`)).toBeInTheDocument();
|
||||||
await screen.findByText(`elastic Editor input: error`);
|
expect(screen.getByText(`elastic Editor input: error`)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles opening split with split open func', async () => {
|
it('handles opening split with split open func', async () => {
|
||||||
@@ -143,40 +187,42 @@ describe('ExplorePage', () => {
|
|||||||
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
|
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
|
||||||
jest.mocked(datasources.elastic.query).mockReturnValue(makeLogsQueryResponse());
|
jest.mocked(datasources.elastic.query).mockReturnValue(makeLogsQueryResponse());
|
||||||
|
|
||||||
// This is mainly to wait for render so that the left pane state is initialized as that is needed for splitOpen
|
// Wait for the left pane to render
|
||||||
// to work
|
await waitFor(async () => {
|
||||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
expect(await screen.findByText(`loki Editor input: { label="value"}`)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
store.dispatch(mainState.splitOpen({ datasourceUid: 'elastic', query: { expr: 'error', refId: 'A' } }));
|
store.dispatch(mainState.splitOpen({ datasourceUid: 'elastic', query: { expr: 'error', refId: 'A' } }));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Editor renders the new query
|
// Editor renders the new query
|
||||||
await screen.findByText(`elastic Editor input: error`);
|
expect(await screen.findByText(`elastic Editor input: error`)).toBeInTheDocument();
|
||||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
expect(await screen.findByText(`loki Editor input: { label="value"}`)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles split size events and sets relevant variables', async () => {
|
it('handles split size events and sets relevant variables', async () => {
|
||||||
setupExplore();
|
setupExplore();
|
||||||
|
|
||||||
const splitButton = await screen.findByText(/split/i);
|
const splitButton = await screen.findByText(/split/i);
|
||||||
await userEvent.click(splitButton);
|
await userEvent.click(splitButton);
|
||||||
await waitForExplore(undefined, true);
|
await waitForExplore(ExploreId.left);
|
||||||
let widenButton = await screen.findAllByLabelText('Widen pane');
|
|
||||||
let narrowButton = await screen.queryAllByLabelText('Narrow pane');
|
expect(await screen.findAllByLabelText('Widen pane')).toHaveLength(2);
|
||||||
|
expect(screen.queryByLabelText('Narrow pane')).not.toBeInTheDocument();
|
||||||
|
|
||||||
const panes = screen.getAllByRole('main');
|
const panes = screen.getAllByRole('main');
|
||||||
expect(widenButton.length).toBe(2);
|
|
||||||
expect(narrowButton.length).toBe(0);
|
|
||||||
expect(Number.parseInt(getComputedStyle(panes[0]).width, 10)).toBe(1000);
|
expect(Number.parseInt(getComputedStyle(panes[0]).width, 10)).toBe(1000);
|
||||||
expect(Number.parseInt(getComputedStyle(panes[1]).width, 10)).toBe(1000);
|
expect(Number.parseInt(getComputedStyle(panes[1]).width, 10)).toBe(1000);
|
||||||
const resizer = screen.getByRole('presentation');
|
const resizer = screen.getByRole('presentation');
|
||||||
|
|
||||||
fireEvent.mouseDown(resizer, { buttons: 1 });
|
fireEvent.mouseDown(resizer, { buttons: 1 });
|
||||||
fireEvent.mouseMove(resizer, { clientX: -700, buttons: 1 });
|
fireEvent.mouseMove(resizer, { clientX: -700, buttons: 1 });
|
||||||
fireEvent.mouseUp(resizer);
|
fireEvent.mouseUp(resizer);
|
||||||
widenButton = await screen.findAllByLabelText('Widen pane');
|
|
||||||
narrowButton = await screen.queryAllByLabelText('Narrow pane');
|
expect(await screen.findAllByLabelText('Widen pane')).toHaveLength(1);
|
||||||
expect(widenButton.length).toBe(1);
|
expect(await screen.findAllByLabelText('Narrow pane')).toHaveLength(1);
|
||||||
expect(narrowButton.length).toBe(1);
|
|
||||||
// the autosizer is mocked so there is no actual resize here
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,205 +260,241 @@ describe('ExplorePage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Handles different URL datasource redirects', () => {
|
describe('Handles different URL datasource redirects', () => {
|
||||||
it('No params, no store value uses default data source', async () => {
|
describe('exploreMixedDatasource on', () => {
|
||||||
setupExplore();
|
beforeAll(() => {
|
||||||
await waitForExplore();
|
config.featureToggles.exploreMixedDatasource = true;
|
||||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
|
||||||
expect(urlParams).toBe(
|
|
||||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('No datasource in root or query and no store value uses default data source', async () => {
|
|
||||||
setupExplore({ urlParams: 'orgId=1&left={"queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}' });
|
|
||||||
await waitForExplore();
|
|
||||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
|
||||||
expect(urlParams).toBe(
|
|
||||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('No datasource in root or query with store value uses store value data source', async () => {
|
|
||||||
setupExplore({
|
|
||||||
urlParams: 'orgId=1&left={"queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}',
|
|
||||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
|
||||||
});
|
});
|
||||||
await waitForExplore();
|
|
||||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
|
||||||
expect(urlParams).toBe(
|
|
||||||
'orgId=1&left={"datasource":"elastic-uid","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('UID datasource in root uses root data source', async () => {
|
describe('When root datasource is not specified in the URL', () => {
|
||||||
setupExplore({
|
it('Redirects to default datasource', async () => {
|
||||||
urlParams:
|
const { location } = setupExplore({ mixedEnabled: true });
|
||||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}',
|
await waitForExplore();
|
||||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const urlParams = decodeURIComponent(location.getSearch().toString());
|
||||||
|
|
||||||
|
expect(urlParams).toBe(
|
||||||
|
'left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(location.getHistory()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Redirects to last used datasource when available', async () => {
|
||||||
|
const { location } = setupExplore({
|
||||||
|
prevUsedDatasource: { orgId: 1, datasource: 'elastic-uid' },
|
||||||
|
mixedEnabled: true,
|
||||||
|
});
|
||||||
|
await waitForExplore();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const urlParams = decodeURIComponent(location.getSearch().toString());
|
||||||
|
expect(urlParams).toBe(
|
||||||
|
'left={"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(location.getHistory()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Redirects to first query's datasource", async () => {
|
||||||
|
const { location } = setupExplore({
|
||||||
|
urlParams: {
|
||||||
|
left: '{"queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
||||||
|
},
|
||||||
|
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
||||||
|
mixedEnabled: true,
|
||||||
|
});
|
||||||
|
await waitForExplore();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const urlParams = decodeURIComponent(location.getSearch().toString());
|
||||||
|
expect(urlParams).toBe(
|
||||||
|
'left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(location.getHistory()).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await waitForExplore();
|
|
||||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
|
||||||
expect(urlParams).toBe(
|
|
||||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Name datasource in root uses root data source, converts to UID', async () => {
|
describe('When root datasource is specified in the URL', () => {
|
||||||
setupExplore({
|
it('Uses the datasource in the URL', async () => {
|
||||||
urlParams: 'orgId=1&left={"datasource":"loki","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}',
|
const { location } = setupExplore({
|
||||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
urlParams: {
|
||||||
|
left: '{"datasource":"elastic-uid","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}',
|
||||||
|
},
|
||||||
|
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
||||||
|
mixedEnabled: true,
|
||||||
|
});
|
||||||
|
await waitForExplore();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const urlParams = decodeURIComponent(location.getSearch().toString());
|
||||||
|
expect(urlParams).toBe(
|
||||||
|
'left={"datasource":"elastic-uid","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}&orgId=1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(location.getHistory()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Filters out queries not using the root datasource', async () => {
|
||||||
|
const { location } = setupExplore({
|
||||||
|
urlParams: {
|
||||||
|
left: '{"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}},{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
||||||
|
},
|
||||||
|
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
||||||
|
mixedEnabled: true,
|
||||||
|
});
|
||||||
|
await waitForExplore();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const urlParams = decodeURIComponent(location.getSearch().toString());
|
||||||
|
expect(urlParams).toBe(
|
||||||
|
'left={"datasource":"elastic-uid","queries":[{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fallbacks to last used datasource if root datasource does not exist', async () => {
|
||||||
|
const { location } = setupExplore({
|
||||||
|
urlParams: { left: '{"datasource":"NON-EXISTENT","range":{"from":"now-1h","to":"now"}}' },
|
||||||
|
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
||||||
|
mixedEnabled: true,
|
||||||
|
});
|
||||||
|
await waitForExplore();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const urlParams = decodeURIComponent(location.getSearch().toString());
|
||||||
|
expect(urlParams).toBe(
|
||||||
|
'left={"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fallbacks to default datasource if root datasource does not exist and last used datasource does not exist', async () => {
|
||||||
|
const { location } = setupExplore({
|
||||||
|
urlParams: { left: '{"datasource":"NON-EXISTENT","range":{"from":"now-1h","to":"now"}}' },
|
||||||
|
prevUsedDatasource: { orgId: 1, datasource: 'I DO NOT EXIST' },
|
||||||
|
mixedEnabled: true,
|
||||||
|
});
|
||||||
|
await waitForExplore();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const urlParams = decodeURIComponent(location.getSearch().toString());
|
||||||
|
expect(urlParams).toBe(
|
||||||
|
'left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fallbacks to default datasource if root datasource does not exist there is no last used datasource', async () => {
|
||||||
|
const { location } = setupExplore({
|
||||||
|
urlParams: { left: '{"datasource":"NON-EXISTENT","range":{"from":"now-1h","to":"now"}}' },
|
||||||
|
mixedEnabled: true,
|
||||||
|
});
|
||||||
|
await waitForExplore();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const urlParams = decodeURIComponent(location.getSearch().toString());
|
||||||
|
expect(urlParams).toBe(
|
||||||
|
'left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await waitForExplore();
|
|
||||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
|
||||||
expect(urlParams).toBe(
|
|
||||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Datasource ref in query, none in root uses query datasource', async () => {
|
it('Queries using nonexisting datasources gets removed', async () => {
|
||||||
setupExplore({
|
const { location } = setupExplore({
|
||||||
urlParams:
|
urlParams: {
|
||||||
'orgId=1&left={"queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
left: '{"datasource":"-- Mixed --","queries":[{"refId":"A","datasource":{"type":"NON-EXISTENT","uid":"NON-EXISTENT"}},{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
||||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
},
|
||||||
|
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
||||||
|
mixedEnabled: true,
|
||||||
|
});
|
||||||
|
await waitForExplore();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const urlParams = decodeURIComponent(location.getSearch().toString());
|
||||||
|
expect(urlParams).toBe(
|
||||||
|
'left={"datasource":"--+Mixed+--","queries":[{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await waitForExplore();
|
|
||||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
|
||||||
expect(urlParams).toBe(
|
|
||||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Datasource ref in query with matching UID in root uses matching datasource', async () => {
|
it('Only keeps queries using root datasource', async () => {
|
||||||
setupExplore({
|
const { location } = setupExplore({
|
||||||
urlParams:
|
urlParams: {
|
||||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
left: '{"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}},{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
||||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
},
|
||||||
|
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
||||||
|
mixedEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForExplore();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const urlParams = decodeURIComponent(location.getSearch().toString());
|
||||||
|
// because there are no import/export queries in our mock datasources, only the first one remains
|
||||||
|
|
||||||
|
expect(urlParams).toBe(
|
||||||
|
'left={"datasource":"elastic-uid","queries":[{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await waitForExplore();
|
|
||||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
|
||||||
expect(urlParams).toBe(
|
|
||||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('Datasource ref in query with matching name in root uses matching datasource, converts root to UID', async () => {
|
describe('exploreMixedDatasource off', () => {
|
||||||
setupExplore({
|
beforeAll(() => {
|
||||||
urlParams:
|
|
||||||
'orgId=1&left={"datasource":"loki","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
|
||||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
|
||||||
});
|
|
||||||
await waitForExplore();
|
|
||||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
|
||||||
expect(urlParams).toBe(
|
|
||||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Datasource ref in query with mismatching UID in root uses query datasource', async () => {
|
|
||||||
setupExplore({
|
|
||||||
urlParams:
|
|
||||||
'orgId=1&left={"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
|
||||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
|
||||||
});
|
|
||||||
await waitForExplore();
|
|
||||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
|
||||||
expect(urlParams).toBe(
|
|
||||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Different datasources in query with mixed feature on changes root to Mixed', async () => {
|
|
||||||
config.featureToggles.exploreMixedDatasource = true;
|
|
||||||
|
|
||||||
setupExplore({
|
|
||||||
urlParams:
|
|
||||||
'orgId=1&left={"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}},{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
|
||||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
|
||||||
});
|
|
||||||
const reducerMock = jest.spyOn(queryState, 'queryReducer');
|
|
||||||
await waitForExplore(undefined, true);
|
|
||||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
|
||||||
expect(reducerMock).not.toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ type: 'explore/queriesImported' })
|
|
||||||
);
|
|
||||||
// this mixed UID is weird just because of our fake datasource generator
|
|
||||||
expect(urlParams).toBe(
|
|
||||||
'orgId=1&left={"datasource":"--+Mixed+---uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}},{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
|
||||||
);
|
|
||||||
|
|
||||||
config.featureToggles.exploreMixedDatasource = false;
|
config.featureToggles.exploreMixedDatasource = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Different datasources in query with mixed feature off uses first query DS, converts rest', async () => {
|
it('Redirects to the first query datasource if the root is mixed', async () => {
|
||||||
config.featureToggles.exploreMixedDatasource = false;
|
const { location } = setupExplore({
|
||||||
setupExplore({
|
urlParams: {
|
||||||
urlParams:
|
left: '{"datasource":"-- Mixed --","queries":[{"refId":"A","datasource":{"type":"logs","uid":"elastic-uid"}},{"refId":"B","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
||||||
'orgId=1&left={"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}},{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
},
|
||||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
mixedEnabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const reducerMock = jest.spyOn(queryState, 'queryReducer');
|
await waitForExplore();
|
||||||
await waitForExplore(undefined, true);
|
|
||||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
await waitFor(() => {
|
||||||
// because there are no import/export queries in our mock datasources, only the first one remains
|
const urlParams = decodeURIComponent(location.getSearch().toString());
|
||||||
expect(reducerMock).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
expect(urlParams).toBe(
|
||||||
expect.objectContaining({
|
'left={"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1'
|
||||||
type: 'explore/queriesImported',
|
);
|
||||||
payload: expect.objectContaining({
|
});
|
||||||
exploreId: 'left',
|
|
||||||
queries: [
|
|
||||||
expect.objectContaining({
|
|
||||||
datasource: {
|
|
||||||
type: 'logs',
|
|
||||||
uid: 'loki-uid',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(urlParams).toBe(
|
|
||||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Datasource in root not found and no queries changes to default', async () => {
|
it('Redirects to the default datasource if the root is mixed and there are no queries', async () => {
|
||||||
setupExplore({
|
const { location } = setupExplore({
|
||||||
urlParams: 'orgId=1&left={"datasource":"asdasdasd","range":{"from":"now-1h","to":"now"}}',
|
urlParams: {
|
||||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
left: '{"datasource":"-- Mixed --","range":{"from":"now-1h","to":"now"}}',
|
||||||
|
},
|
||||||
|
mixedEnabled: false,
|
||||||
});
|
});
|
||||||
await waitForExplore();
|
|
||||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
|
||||||
expect(urlParams).toBe(
|
|
||||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Datasource root is mixed and there are two queries, one with datasource not found, only one query remains with root datasource as that datasource', async () => {
|
await waitForExplore();
|
||||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
||||||
setupExplore({
|
await waitFor(() => {
|
||||||
urlParams:
|
const urlParams = decodeURIComponent(location.getSearch().toString());
|
||||||
'orgId=1&left={"datasource":"-- Mixed --","queries":[{"refId":"A","datasource":{"type":"asdf","uid":"asdf"}},{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
|
||||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
expect(urlParams).toBe(
|
||||||
|
'left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
await waitForExplore();
|
|
||||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
|
||||||
expect(urlParams).toBe(
|
|
||||||
'orgId=1&left={"datasource":"elastic-uid","queries":[{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
|
||||||
);
|
|
||||||
expect(consoleErrorSpy).toBeCalledTimes(1);
|
|
||||||
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes `from` and `to` parameters from url when first mounted', async () => {
|
it('removes `from` and `to` parameters from url when first mounted', async () => {
|
||||||
setupExplore({ searchParams: 'from=1&to=2&orgId=1' });
|
const { location } = setupExplore({ urlParams: { from: '1', to: '2' } });
|
||||||
await waitForExplore();
|
await waitForExplore();
|
||||||
|
|
||||||
expect(locationService.getSearchObject()).toEqual(expect.not.objectContaining({ from: '1', to: '2' }));
|
await waitFor(() => {
|
||||||
expect(locationService.getSearchObject()).toEqual(expect.objectContaining({ orgId: '1' }));
|
expect(location.getSearchObject()).toEqual(expect.not.objectContaining({ from: '1', to: '2' }));
|
||||||
|
expect(location.getSearchObject()).toEqual(expect.objectContaining({ orgId: '1' }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,25 +1,25 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { inRange } from 'lodash';
|
import { inRange } from 'lodash';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useWindowSize } from 'react-use';
|
import { useWindowSize } from 'react-use';
|
||||||
|
|
||||||
import { isTruthy } from '@grafana/data';
|
import { ErrorBoundaryAlert } from '@grafana/ui';
|
||||||
import { locationService } from '@grafana/runtime';
|
|
||||||
import { ErrorBoundaryAlert, usePanelContext } from '@grafana/ui';
|
|
||||||
import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper';
|
import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper';
|
||||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
|
||||||
import { useNavModel } from 'app/core/hooks/useNavModel';
|
import { useNavModel } from 'app/core/hooks/useNavModel';
|
||||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||||
import { useDispatch, useSelector } from 'app/types';
|
import { useDispatch, useSelector } from 'app/types';
|
||||||
import { ExploreId, ExploreQueryParams } from 'app/types/explore';
|
import { ExploreId, ExploreQueryParams } from 'app/types/explore';
|
||||||
|
|
||||||
import { Branding } from '../../core/components/Branding/Branding';
|
|
||||||
import { useCorrelations } from '../correlations/useCorrelations';
|
|
||||||
|
|
||||||
import { ExploreActions } from './ExploreActions';
|
import { ExploreActions } from './ExploreActions';
|
||||||
import { ExplorePaneContainer } from './ExplorePaneContainer';
|
import { ExplorePaneContainer } from './ExplorePaneContainer';
|
||||||
import { lastSavedUrl, saveCorrelationsAction, resetExploreAction, splitSizeUpdateAction } from './state/main';
|
import { useExploreCorrelations } from './hooks/useExploreCorrelations';
|
||||||
|
import { useExplorePageTitle } from './hooks/useExplorePageTitle';
|
||||||
|
import { useStateSync } from './hooks/useStateSync';
|
||||||
|
import { useStopQueries } from './hooks/useStopQueries';
|
||||||
|
import { useTimeSrvFix } from './hooks/useTimeSrvFix';
|
||||||
|
import { splitSizeUpdateAction } from './state/main';
|
||||||
|
import { selectOrderedExplorePanes } from './state/selectors';
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
pageScrollbarWrapper: css`
|
pageScrollbarWrapper: css`
|
||||||
@@ -32,20 +32,21 @@ const styles = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ExplorePage(props: GrafanaRouteComponentProps<{}, ExploreQueryParams>) {
|
export default function ExplorePage(props: GrafanaRouteComponentProps<{}, ExploreQueryParams>) {
|
||||||
|
useStopQueries();
|
||||||
|
useTimeSrvFix();
|
||||||
|
useStateSync(props.queryParams);
|
||||||
useExplorePageTitle();
|
useExplorePageTitle();
|
||||||
|
useExploreCorrelations();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const queryParams = props.queryParams;
|
const { keybindings, chrome } = useGrafana();
|
||||||
const { keybindings, chrome, config } = useGrafana();
|
|
||||||
const navModel = useNavModel('explore');
|
const navModel = useNavModel('explore');
|
||||||
const { get } = useCorrelations();
|
|
||||||
const { warning } = useAppNotification();
|
|
||||||
const panelCtx = usePanelContext();
|
|
||||||
const eventBus = useRef(panelCtx.eventBus.newScopedBus('explore', { onlyLocal: false }));
|
|
||||||
const [rightPaneWidthRatio, setRightPaneWidthRatio] = useState(0.5);
|
const [rightPaneWidthRatio, setRightPaneWidthRatio] = useState(0.5);
|
||||||
const { width: windowWidth } = useWindowSize();
|
const { width: windowWidth } = useWindowSize();
|
||||||
const minWidth = 200;
|
const minWidth = 200;
|
||||||
const exploreState = useSelector((state) => state.explore);
|
const exploreState = useSelector((state) => state.explore);
|
||||||
|
|
||||||
|
const panes = useSelector(selectOrderedExplorePanes);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
//This is needed for breadcrumbs and topnav.
|
//This is needed for breadcrumbs and topnav.
|
||||||
//We should probably abstract this out at some point
|
//We should probably abstract this out at some point
|
||||||
@@ -56,52 +57,6 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
|
|||||||
keybindings.setupTimeRangeBindings(false);
|
keybindings.setupTimeRangeBindings(false);
|
||||||
}, [keybindings]);
|
}, [keybindings]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!config.featureToggles.correlations) {
|
|
||||||
dispatch(saveCorrelationsAction([]));
|
|
||||||
} else {
|
|
||||||
get.execute();
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (get.value) {
|
|
||||||
dispatch(saveCorrelationsAction(get.value));
|
|
||||||
} else if (get.error) {
|
|
||||||
dispatch(saveCorrelationsAction([]));
|
|
||||||
warning(
|
|
||||||
'Could not load correlations.',
|
|
||||||
'Correlations data could not be loaded, DataLinks may have partial data.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [get.value, get.error, dispatch, warning]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
lastSavedUrl.left = undefined;
|
|
||||||
lastSavedUrl.right = undefined;
|
|
||||||
|
|
||||||
// timeSrv (which is used internally) on init reads `from` and `to` param from the URL and updates itself
|
|
||||||
// using those value regardless of what is passed to the init method.
|
|
||||||
// The updated value is then used by Explore to get the range for each pane.
|
|
||||||
// This means that if `from` and `to` parameters are present in the URL,
|
|
||||||
// it would be impossible to change the time range in Explore.
|
|
||||||
// We are only doing this on mount for 2 reasons:
|
|
||||||
// 1: Doing it on update means we'll enter a render loop.
|
|
||||||
// 2: when parsing time in Explore (before feeding it to timeSrv) we make sure `from` is before `to` inside
|
|
||||||
// each pane state in order to not trigger un URL update from timeSrv.
|
|
||||||
const searchParams = locationService.getSearchObject();
|
|
||||||
if (searchParams.from || searchParams.to) {
|
|
||||||
locationService.partial({ from: undefined, to: undefined }, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Cleaning up Explore state so that when navigating back to Explore it starts from a blank state
|
|
||||||
dispatch(resetExploreAction());
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- dispatch is stable, doesn't need to be in the deps array
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateSplitSize = (size: number) => {
|
const updateSplitSize = (size: number) => {
|
||||||
const evenSplitWidth = windowWidth / 2;
|
const evenSplitWidth = windowWidth / 2;
|
||||||
const areBothSimilar = inRange(size, evenSplitWidth - 100, evenSplitWidth + 100);
|
const areBothSimilar = inRange(size, evenSplitWidth - 100, evenSplitWidth + 100);
|
||||||
@@ -118,7 +73,7 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
|
|||||||
setRightPaneWidthRatio(size / windowWidth);
|
setRightPaneWidthRatio(size / windowWidth);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasSplit = Boolean(queryParams.left) && Boolean(queryParams.right);
|
const hasSplit = Object.entries(panes).length > 1;
|
||||||
let widthCalc = 0;
|
let widthCalc = 0;
|
||||||
if (hasSplit) {
|
if (hasSplit) {
|
||||||
if (!exploreState.evenSplitPanes && exploreState.maxedExploreId) {
|
if (!exploreState.evenSplitPanes && exploreState.maxedExploreId) {
|
||||||
@@ -148,30 +103,14 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ErrorBoundaryAlert style="page">
|
{Object.keys(panes).map((exploreId) => {
|
||||||
<ExplorePaneContainer exploreId={ExploreId.left} urlQuery={queryParams.left} eventBus={eventBus.current} />
|
return (
|
||||||
</ErrorBoundaryAlert>
|
<ErrorBoundaryAlert key={exploreId} style="page">
|
||||||
{hasSplit && (
|
<ExplorePaneContainer exploreId={exploreId as ExploreId} />
|
||||||
<ErrorBoundaryAlert style="page">
|
</ErrorBoundaryAlert>
|
||||||
<ExplorePaneContainer
|
);
|
||||||
exploreId={ExploreId.right}
|
})}
|
||||||
urlQuery={queryParams.right}
|
|
||||||
eventBus={eventBus.current}
|
|
||||||
/>
|
|
||||||
</ErrorBoundaryAlert>
|
|
||||||
)}
|
|
||||||
</SplitPaneWrapper>
|
</SplitPaneWrapper>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const useExplorePageTitle = () => {
|
|
||||||
const navModel = useNavModel('explore');
|
|
||||||
const datasources = useSelector((state) =>
|
|
||||||
[state.explore.panes.left!.datasourceInstance?.name, state.explore.panes.right?.datasourceInstance?.name].filter(
|
|
||||||
isTruthy
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
document.title = `${navModel.main.text} - ${datasources.join(' | ')} - ${Branding.AppTitle}`;
|
|
||||||
};
|
|
||||||
|
@@ -1,35 +1,14 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import memoizeOne from 'memoize-one';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import React from 'react';
|
import { connect } from 'react-redux';
|
||||||
import { connect, ConnectedProps } from 'react-redux';
|
|
||||||
|
|
||||||
import { EventBusExtended, EventBusSrv, GrafanaTheme2, EventBus } from '@grafana/data';
|
import { EventBusSrv, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { reportInteraction } from '@grafana/runtime';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
import { Themeable2, withTheme2 } from '@grafana/ui';
|
|
||||||
import { config } from 'app/core/config';
|
|
||||||
import store from 'app/core/store';
|
|
||||||
import {
|
|
||||||
DEFAULT_RANGE,
|
|
||||||
ensureQueries,
|
|
||||||
queryDatasourceDetails,
|
|
||||||
getTimeRange,
|
|
||||||
getTimeRangeFromUrl,
|
|
||||||
lastUsedDatasourceKeyForOrgId,
|
|
||||||
parseUrlState,
|
|
||||||
} from 'app/core/utils/explore';
|
|
||||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
|
||||||
import { StoreState } from 'app/types';
|
import { StoreState } from 'app/types';
|
||||||
import { ExploreId } from 'app/types/explore';
|
import { ExploreId } from 'app/types/explore';
|
||||||
|
|
||||||
import { getDatasourceSrv } from '../plugins/datasource_srv';
|
|
||||||
import { getFiscalYearStartMonth, getTimeZone } from '../profile/state/selectors';
|
|
||||||
|
|
||||||
import Explore from './Explore';
|
import Explore from './Explore';
|
||||||
import { initializeExplore, refreshExplore } from './state/explorePane';
|
|
||||||
import { lastSavedUrl, stateSave } from './state/main';
|
|
||||||
import { importQueries } from './state/query';
|
|
||||||
import { loadAndInitDatasource } from './state/utils';
|
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => {
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
return {
|
return {
|
||||||
@@ -46,158 +25,42 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
interface OwnProps extends Themeable2 {
|
interface Props {
|
||||||
exploreId: ExploreId;
|
exploreId: ExploreId;
|
||||||
urlQuery: string;
|
|
||||||
eventBus: EventBus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props extends OwnProps, ConnectedProps<typeof connector> {}
|
/*
|
||||||
|
Connected components subscribe to the store before function components (using hooks) and can react to store changes. Thus, this connector function is called before the parent component (ExplorePage) is rerendered.
|
||||||
|
This means that child components' mapStateToProps will be executed with a zombie `exploreId` that is not present anymore in the store if the pane gets closed.
|
||||||
|
By connecting this component and returning the pane we workaround the zombie children issue here instead of modifying every children.
|
||||||
|
This is definitely not the ideal solution and we should in the future invest more time in exploring other approaches to better handle this scenario, potentially by refactoring panels to be function components
|
||||||
|
(therefore immune to this behaviour), or by forbidding them to access the store directly and instead pass them all the data they need via props or context.
|
||||||
|
|
||||||
/**
|
You can read more about this issue here: https://react-redux.js.org/api/hooks#stale-props-and-zombie-children
|
||||||
* This component is responsible for handling initialization of an Explore pane and triggering synchronization
|
*/
|
||||||
* of state based on URL changes and preventing any infinite loops.
|
function ExplorePaneContainerUnconnected({ exploreId }: Props) {
|
||||||
*/
|
const styles = useStyles2(getStyles);
|
||||||
class ExplorePaneContainerUnconnected extends React.PureComponent<Props> {
|
const eventBus = useRef(new EventBusSrv());
|
||||||
el: HTMLDivElement | null = null;
|
const ref = useRef(null);
|
||||||
exploreEvents: EventBusExtended;
|
|
||||||
|
|
||||||
constructor(props: Props) {
|
useEffect(() => {
|
||||||
super(props);
|
const bus = eventBus.current;
|
||||||
this.exploreEvents = new EventBusSrv();
|
return () => bus.removeAllListeners();
|
||||||
this.state = {
|
}, []);
|
||||||
openDrawer: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async componentDidMount() {
|
return (
|
||||||
const {
|
<div className={styles.explore} ref={ref} data-testid={selectors.pages.Explore.General.container}>
|
||||||
initialized,
|
<Explore exploreId={exploreId} eventBus={eventBus.current} />
|
||||||
exploreId,
|
</div>
|
||||||
initialDatasource,
|
);
|
||||||
initialQueries,
|
|
||||||
initialRange,
|
|
||||||
panelsState,
|
|
||||||
orgId,
|
|
||||||
isFromCompactUrl,
|
|
||||||
} = this.props;
|
|
||||||
const width = this.el?.offsetWidth ?? 0;
|
|
||||||
// initialize the whole explore first time we mount and if browser history contains a change in datasource
|
|
||||||
if (!initialized) {
|
|
||||||
let queriesDatasourceOverride = undefined;
|
|
||||||
let rootDatasourceOverride = undefined;
|
|
||||||
// if this is starting with no queries and an initial datasource exists (but is not mixed), look up the ref to use it (initial datasource can be a UID or name here)
|
|
||||||
if ((!initialQueries || initialQueries.length === 0) && initialDatasource) {
|
|
||||||
const isDSMixed =
|
|
||||||
initialDatasource === MIXED_DATASOURCE_NAME || initialDatasource.uid === MIXED_DATASOURCE_NAME;
|
|
||||||
if (!isDSMixed) {
|
|
||||||
const { instance } = await loadAndInitDatasource(orgId, initialDatasource);
|
|
||||||
queriesDatasourceOverride = instance.getRef();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let queries = await ensureQueries(initialQueries, queriesDatasourceOverride); // this will return an empty array if there are no datasources
|
|
||||||
|
|
||||||
const queriesDatasourceDetails = queryDatasourceDetails(queries);
|
|
||||||
if (!queriesDatasourceDetails.noneHaveDatasource) {
|
|
||||||
if (!queryDatasourceDetails(queries).allDatasourceSame) {
|
|
||||||
if (config.featureToggles.exploreMixedDatasource) {
|
|
||||||
rootDatasourceOverride = await getDatasourceSrv().get(MIXED_DATASOURCE_NAME);
|
|
||||||
} else {
|
|
||||||
// if we have mixed queries but the mixed datasource feature is not on, change the datasource to the first query that has one
|
|
||||||
const changeDatasourceUid = queries.find((query) => query.datasource?.uid)!.datasource!.uid;
|
|
||||||
if (changeDatasourceUid) {
|
|
||||||
rootDatasourceOverride = changeDatasourceUid;
|
|
||||||
const datasource = await getDatasourceSrv().get(changeDatasourceUid);
|
|
||||||
const datasourceInit = await getDatasourceSrv().get(initialDatasource);
|
|
||||||
const newQueries = await this.props.importQueries(exploreId, queries, datasourceInit, datasource);
|
|
||||||
await this.props.stateSave({ replace: true });
|
|
||||||
queries = newQueries ?? this.props.initialQueries;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFromCompactUrl) {
|
|
||||||
reportInteraction('grafana_explore_compact_notice');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.initializeExplore({
|
|
||||||
exploreId,
|
|
||||||
datasource: rootDatasourceOverride || queries[0]?.datasource || initialDatasource,
|
|
||||||
queries,
|
|
||||||
range: initialRange,
|
|
||||||
containerWidth: width,
|
|
||||||
eventBridge: this.exploreEvents,
|
|
||||||
panelsState,
|
|
||||||
isFromCompactUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this.exploreEvents.removeAllListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps: Props) {
|
|
||||||
this.refreshExplore(prevProps.urlQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshExplore = (prevUrlQuery: string) => {
|
|
||||||
const { exploreId, urlQuery } = this.props;
|
|
||||||
|
|
||||||
// Update state from url only if it changed and only if the change wasn't initialised by redux to prevent any loops
|
|
||||||
if (urlQuery !== prevUrlQuery && urlQuery !== lastSavedUrl[exploreId]) {
|
|
||||||
this.props.refreshExplore(exploreId, urlQuery);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
getRef = (el: HTMLDivElement) => {
|
|
||||||
this.el = el;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { theme, exploreId, initialized, eventBus } = this.props;
|
|
||||||
const styles = getStyles(theme);
|
|
||||||
return (
|
|
||||||
<div className={styles.explore} ref={this.getRef} data-testid={selectors.pages.Explore.General.container}>
|
|
||||||
{initialized && <Explore exploreId={exploreId} eventBus={eventBus} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTimeRangeFromUrlMemoized = memoizeOne(getTimeRangeFromUrl);
|
function mapStateToProps(state: StoreState, props: Props) {
|
||||||
|
const pane = state.explore.panes[props.exploreId];
|
||||||
|
|
||||||
function mapStateToProps(state: StoreState, props: OwnProps) {
|
return { pane };
|
||||||
const urlState = parseUrlState(props.urlQuery);
|
|
||||||
const timeZone = getTimeZone(state.user);
|
|
||||||
const fiscalYearStartMonth = getFiscalYearStartMonth(state.user);
|
|
||||||
|
|
||||||
const { datasource, queries, range: urlRange, panelsState } = urlState || {};
|
|
||||||
const initialDatasource = datasource || store.get(lastUsedDatasourceKeyForOrgId(state.user.orgId));
|
|
||||||
const initialRange = urlRange
|
|
||||||
? getTimeRangeFromUrlMemoized(urlRange, timeZone, fiscalYearStartMonth)
|
|
||||||
: getTimeRange(timeZone, DEFAULT_RANGE, fiscalYearStartMonth);
|
|
||||||
|
|
||||||
return {
|
|
||||||
initialized: state.explore.panes[props.exploreId]?.initialized,
|
|
||||||
initialDatasource,
|
|
||||||
initialQueries: queries,
|
|
||||||
initialRange,
|
|
||||||
panelsState,
|
|
||||||
orgId: state.user.orgId,
|
|
||||||
isFromCompactUrl: urlState.isFromCompactUrl || false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const connector = connect(mapStateToProps);
|
||||||
initializeExplore,
|
|
||||||
refreshExplore,
|
|
||||||
importQueries,
|
|
||||||
stateSave,
|
|
||||||
};
|
|
||||||
|
|
||||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
export const ExplorePaneContainer = connector(ExplorePaneContainerUnconnected);
|
||||||
|
|
||||||
export const ExplorePaneContainer = withTheme2(connector(ExplorePaneContainerUnconnected));
|
|
||||||
|
@@ -68,7 +68,9 @@ export function ExploreQueryInspector(props: Props) {
|
|||||||
label: 'Query',
|
label: 'Query',
|
||||||
value: 'query',
|
value: 'query',
|
||||||
icon: 'info-circle',
|
icon: 'info-circle',
|
||||||
content: <QueryInspector data={dataFrames} onRefreshQuery={() => props.runQueries(props.exploreId)} />,
|
content: (
|
||||||
|
<QueryInspector data={dataFrames} onRefreshQuery={() => props.runQueries({ exploreId: props.exploreId })} />
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabs = [statsTab, queryTab, jsonTab, dataTab];
|
const tabs = [statsTab, queryTab, jsonTab, dataTab];
|
||||||
|
@@ -88,7 +88,7 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props)
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return dispatch(cancelQueries(exploreId));
|
return dispatch(cancelQueries(exploreId));
|
||||||
} else {
|
} else {
|
||||||
return dispatch(runQueries(exploreId));
|
return dispatch(runQueries({ exploreId }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,8 +119,8 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props)
|
|||||||
const onChangeFiscalYearStartMonth = (fiscalyearStartMonth: number) =>
|
const onChangeFiscalYearStartMonth = (fiscalyearStartMonth: number) =>
|
||||||
dispatch(updateFiscalYearStartMonthForSession(fiscalyearStartMonth));
|
dispatch(updateFiscalYearStartMonthForSession(fiscalyearStartMonth));
|
||||||
|
|
||||||
const onChangeRefreshInterval = (item: string) => {
|
const onChangeRefreshInterval = (refreshInterval: string) => {
|
||||||
dispatch(changeRefreshInterval(exploreId, item));
|
dispatch(changeRefreshInterval({ exploreId, refreshInterval }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const showExploreToDashboard = useMemo(
|
const showExploreToDashboard = useMemo(
|
||||||
|
@@ -11,7 +11,7 @@ import { ExploreId } from 'app/types/explore';
|
|||||||
import { getDatasourceSrv } from '../plugins/datasource_srv';
|
import { getDatasourceSrv } from '../plugins/datasource_srv';
|
||||||
import { QueryEditorRows } from '../query/components/QueryEditorRows';
|
import { QueryEditorRows } from '../query/components/QueryEditorRows';
|
||||||
|
|
||||||
import { runQueries, changeQueries } from './state/query';
|
import { changeQueries, runQueries } from './state/query';
|
||||||
import { getExploreItemSelector } from './state/selectors';
|
import { getExploreItemSelector } from './state/selectors';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -39,14 +39,14 @@ export const QueryRows = ({ exploreId }: Props) => {
|
|||||||
[exploreId]
|
[exploreId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const queries = useSelector(getQueries)!;
|
const queries = useSelector(getQueries);
|
||||||
const dsSettings = useSelector(getDatasourceInstanceSettings)!;
|
const dsSettings = useSelector(getDatasourceInstanceSettings);
|
||||||
const queryResponse = useSelector(getQueryResponse)!;
|
const queryResponse = useSelector(getQueryResponse);
|
||||||
const history = useSelector(getHistory);
|
const history = useSelector(getHistory);
|
||||||
const eventBridge = useSelector(getEventBridge);
|
const eventBridge = useSelector(getEventBridge);
|
||||||
|
|
||||||
const onRunQueries = useCallback(() => {
|
const onRunQueries = useCallback(() => {
|
||||||
dispatch(runQueries(exploreId));
|
dispatch(runQueries({ exploreId }));
|
||||||
}, [dispatch, exploreId]);
|
}, [dispatch, exploreId]);
|
||||||
|
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
|
@@ -1,14 +1,15 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Provider } from 'react-redux';
|
import { TestProvider } from 'test/helpers/TestProvider';
|
||||||
|
|
||||||
import { DataQueryError, LoadingState, getDefaultTimeRange } from '@grafana/data';
|
import { DataQueryError, LoadingState } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { ExploreId } from 'app/types';
|
import { ExploreId } from 'app/types';
|
||||||
|
|
||||||
import { configureStore } from '../../store/configureStore';
|
import { configureStore } from '../../store/configureStore';
|
||||||
|
|
||||||
import { ResponseErrorContainer } from './ResponseErrorContainer';
|
import { ResponseErrorContainer } from './ResponseErrorContainer';
|
||||||
|
import { createEmptyQueryResponse, makeExplorePaneState } from './state/utils';
|
||||||
|
|
||||||
describe('ResponseErrorContainer', () => {
|
describe('ResponseErrorContainer', () => {
|
||||||
it('shows error message if it does not contain refId', async () => {
|
it('shows error message if it does not contain refId', async () => {
|
||||||
@@ -46,26 +47,20 @@ describe('ResponseErrorContainer', () => {
|
|||||||
|
|
||||||
function setup(error: DataQueryError) {
|
function setup(error: DataQueryError) {
|
||||||
const store = configureStore();
|
const store = configureStore();
|
||||||
store.getState().explore.panes.left!.queryResponse = {
|
store.getState().explore.panes = {
|
||||||
timeRange: getDefaultTimeRange(),
|
left: {
|
||||||
series: [],
|
...makeExplorePaneState(),
|
||||||
state: LoadingState.Error,
|
queryResponse: {
|
||||||
error,
|
...createEmptyQueryResponse(),
|
||||||
graphFrames: [],
|
state: LoadingState.Error,
|
||||||
logsFrames: [],
|
error,
|
||||||
tableFrames: [],
|
},
|
||||||
traceFrames: [],
|
},
|
||||||
nodeGraphFrames: [],
|
|
||||||
rawPrometheusFrames: [],
|
|
||||||
flameGraphFrames: [],
|
|
||||||
graphResult: null,
|
|
||||||
logsResult: null,
|
|
||||||
tableResult: null,
|
|
||||||
rawPrometheusResult: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<Provider store={store}>
|
<TestProvider store={store}>
|
||||||
<ResponseErrorContainer exploreId={ExploreId.left} />
|
<ResponseErrorContainer exploreId={ExploreId.left} />
|
||||||
</Provider>
|
</TestProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
35
public/app/features/explore/hooks/useExploreCorrelations.ts
Normal file
35
public/app/features/explore/hooks/useExploreCorrelations.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { useCorrelations } from 'app/features/correlations/useCorrelations';
|
||||||
|
import { useDispatch } from 'app/types';
|
||||||
|
|
||||||
|
import { saveCorrelationsAction } from '../state/main';
|
||||||
|
|
||||||
|
export function useExploreCorrelations() {
|
||||||
|
const { get } = useCorrelations();
|
||||||
|
const { warning } = useAppNotification();
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config.featureToggles.correlations) {
|
||||||
|
dispatch(saveCorrelationsAction([]));
|
||||||
|
} else {
|
||||||
|
get.execute();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (get.value) {
|
||||||
|
dispatch(saveCorrelationsAction(get.value));
|
||||||
|
} else if (get.error) {
|
||||||
|
dispatch(saveCorrelationsAction([]));
|
||||||
|
warning(
|
||||||
|
'Could not load correlations.',
|
||||||
|
'Correlations data could not be loaded, DataLinks may have partial data.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [get.value, get.error, dispatch, warning]);
|
||||||
|
}
|
16
public/app/features/explore/hooks/useExplorePageTitle.ts
Normal file
16
public/app/features/explore/hooks/useExplorePageTitle.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { isTruthy } from '@grafana/data';
|
||||||
|
import { Branding } from 'app/core/components/Branding/Branding';
|
||||||
|
import { useNavModel } from 'app/core/hooks/useNavModel';
|
||||||
|
import { useSelector } from 'app/types';
|
||||||
|
|
||||||
|
import { selectOrderedExplorePanes } from '../state/selectors';
|
||||||
|
|
||||||
|
export function useExplorePageTitle() {
|
||||||
|
const navModel = useNavModel('explore');
|
||||||
|
|
||||||
|
const datasourceNames = useSelector((state) =>
|
||||||
|
Object.values(selectOrderedExplorePanes(state)).map((pane) => pane?.datasourceInstance?.name)
|
||||||
|
).filter(isTruthy);
|
||||||
|
|
||||||
|
document.title = `${navModel.main.text} - ${datasourceNames.join(' | ')} - ${Branding.AppTitle}`;
|
||||||
|
}
|
409
public/app/features/explore/hooks/useStateSync.ts
Normal file
409
public/app/features/explore/hooks/useStateSync.ts
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
import { isEmpty, isEqual, isObject, mapValues, omitBy } from 'lodash';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CoreApp,
|
||||||
|
serializeStateToUrlParam,
|
||||||
|
ExploreUrlState,
|
||||||
|
isDateTime,
|
||||||
|
TimeRange,
|
||||||
|
RawTimeRange,
|
||||||
|
DataSourceApi,
|
||||||
|
} from '@grafana/data';
|
||||||
|
import { DataQuery, DataSourceRef } from '@grafana/schema';
|
||||||
|
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||||
|
import { clearQueryKeys, getLastUsedDatasourceUID, parseUrlState } from 'app/core/utils/explore';
|
||||||
|
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||||
|
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||||
|
import { addListener, ExploreId, ExploreItemState, ExploreQueryParams, useDispatch, useSelector } from 'app/types';
|
||||||
|
|
||||||
|
import { changeDatasource } from '../state/datasource';
|
||||||
|
import { initializeExplore } from '../state/explorePane';
|
||||||
|
import { clearPanes, splitClose, splitOpen, syncTimesAction } from '../state/main';
|
||||||
|
import { runQueries, setQueriesAction } from '../state/query';
|
||||||
|
import { selectPanes } from '../state/selectors';
|
||||||
|
import { changeRangeAction, updateTime } from '../state/time';
|
||||||
|
import { DEFAULT_RANGE } from '../state/utils';
|
||||||
|
import { withUniqueRefIds } from '../utils/queries';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bi-directionally syncs URL changes with Explore's state.
|
||||||
|
*/
|
||||||
|
export function useStateSync(params: ExploreQueryParams) {
|
||||||
|
const {
|
||||||
|
location,
|
||||||
|
config: {
|
||||||
|
featureToggles: { exploreMixedDatasource },
|
||||||
|
},
|
||||||
|
} = useGrafana();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const statePanes = useSelector(selectPanes);
|
||||||
|
const orgId = useSelector((state) => state.user.orgId);
|
||||||
|
const prevParams = useRef<ExploreQueryParams>(params);
|
||||||
|
const initState = useRef<'notstarted' | 'pending' | 'done'>('notstarted');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// This happens when the user navigates to an explore "empty page" while within Explore.
|
||||||
|
// ie. by clicking on the explore when explore is active.
|
||||||
|
if (!params.left && !params.right) {
|
||||||
|
initState.current = 'notstarted';
|
||||||
|
prevParams.current = params;
|
||||||
|
}
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = dispatch(
|
||||||
|
addListener({
|
||||||
|
predicate: (action) =>
|
||||||
|
// We want to update the URL when:
|
||||||
|
// - a pane is opened or closed
|
||||||
|
// - a query is run
|
||||||
|
// - range is changed
|
||||||
|
[splitClose.type, splitOpen.fulfilled.type, runQueries.pending.type, changeRangeAction.type].includes(
|
||||||
|
action.type
|
||||||
|
),
|
||||||
|
effect: async (_, { cancelActiveListeners, delay, getState }) => {
|
||||||
|
// The following 2 lines will throttle updates to avoid creating history entries when rapid changes
|
||||||
|
// are committed to the store.
|
||||||
|
cancelActiveListeners();
|
||||||
|
await delay(200);
|
||||||
|
|
||||||
|
const panesQueryParams = Object.entries(getState().explore.panes).reduce<Record<string, string>>(
|
||||||
|
(acc, [id, paneState]) => ({ ...acc, [id]: serializeStateToUrlParam(getUrlStateFromPaneState(paneState)) }),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isEqual(prevParams.current, panesQueryParams)) {
|
||||||
|
// If there's no previous state it means we are mounting explore for the first time,
|
||||||
|
// in this case we want to replace the URL instead of pushing a new entry to the history.
|
||||||
|
const replace = Object.values(prevParams.current).filter(Boolean).length === 0;
|
||||||
|
|
||||||
|
prevParams.current = panesQueryParams;
|
||||||
|
|
||||||
|
location.partial(
|
||||||
|
{ left: panesQueryParams.left, right: panesQueryParams.right, orgId: getState().user.orgId },
|
||||||
|
replace
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// @ts-expect-error the return type of addListener is actually callable, but dispatch is not middleware-aware
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [dispatch, location]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isURLOutOfSync = prevParams.current?.left !== params.left || prevParams.current?.right !== params.right;
|
||||||
|
|
||||||
|
const urlPanes = {
|
||||||
|
left: parseUrlState(params.left),
|
||||||
|
...(params.right && { right: parseUrlState(params.right) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function sync() {
|
||||||
|
// if navigating the history causes one of the time range to not being equal to all the other ones,
|
||||||
|
// we set syncedTimes to false to avoid inconsistent UI state.
|
||||||
|
// Ideally `syncedTimes` should be saved in the URL.
|
||||||
|
if (Object.values(urlPanes).some(({ range }, _, [{ range: firstRange }]) => !isEqual(range, firstRange))) {
|
||||||
|
dispatch(syncTimesAction({ syncedTimes: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [exploreId, urlPane] of Object.entries(urlPanes) as Array<[ExploreId, ExploreUrlState]>) {
|
||||||
|
const { datasource, queries, range, panelsState } = urlPane;
|
||||||
|
|
||||||
|
const statePane = statePanes[exploreId];
|
||||||
|
|
||||||
|
if (statePane !== undefined) {
|
||||||
|
// TODO: the diff contains panelState updates, but we are currently not handling them.
|
||||||
|
const update = urlDiff(urlPane, getUrlStateFromPaneState(statePane));
|
||||||
|
|
||||||
|
Promise.resolve()
|
||||||
|
.then(async () => {
|
||||||
|
if (update.datasource) {
|
||||||
|
await dispatch(changeDatasource(exploreId, datasource));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (update.range) {
|
||||||
|
dispatch(updateTime({ exploreId, rawRange: range }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update.queries) {
|
||||||
|
dispatch(setQueriesAction({ exploreId, queries: withUniqueRefIds(queries) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update.queries || update.range) {
|
||||||
|
dispatch(runQueries({ exploreId }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// This happens when browser history is used to navigate.
|
||||||
|
// In this case we want to initialize the pane with the data from the URL
|
||||||
|
// if it's not present in the store. This may happen if the user has navigated
|
||||||
|
// from split view to non-split view and then back to split view.
|
||||||
|
dispatch(
|
||||||
|
initializeExplore({
|
||||||
|
exploreId,
|
||||||
|
datasource,
|
||||||
|
queries: withUniqueRefIds(queries),
|
||||||
|
range,
|
||||||
|
panelsState,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close all the panes that are not in the URL but are still in the store
|
||||||
|
// ie. because the user has navigated back after opening the split view.
|
||||||
|
Object.keys(statePanes)
|
||||||
|
.filter((keyInStore) => !Object.keys(urlPanes).includes(keyInStore))
|
||||||
|
.forEach((paneId) => dispatch(splitClose(paneId as ExploreId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// This happens when the user first navigates to explore.
|
||||||
|
// Here we want to initialize each pane initial data, wether it comes
|
||||||
|
// from the url or as a result of migrations.
|
||||||
|
if (!isURLOutOfSync && initState.current === 'notstarted') {
|
||||||
|
initState.current = 'pending';
|
||||||
|
|
||||||
|
// Clear all the panes in the store first to avoid stale data.
|
||||||
|
dispatch(clearPanes());
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
Object.entries(urlPanes).map(([exploreId, { datasource, queries, range, panelsState }]) => {
|
||||||
|
return getPaneDatasource(datasource, queries, orgId, !!exploreMixedDatasource).then(
|
||||||
|
async (paneDatasource) => {
|
||||||
|
return Promise.resolve(
|
||||||
|
// In theory, given the Grafana datasource will always be present, this should always be defined.
|
||||||
|
paneDatasource
|
||||||
|
? queries.length
|
||||||
|
? // if we have queries in the URL, we use them
|
||||||
|
withUniqueRefIds(queries)
|
||||||
|
// but filter out the ones that are not compatible with the pane datasource
|
||||||
|
.filter(getQueryFilter(paneDatasource))
|
||||||
|
: getDatasourceSrv()
|
||||||
|
// otherwise we get a default query from the pane datasource or from the default datasource if the pane datasource is mixed
|
||||||
|
.get(isMixedDatasource(paneDatasource) ? undefined : paneDatasource.getRef())
|
||||||
|
.then((ds) => [getDefaultQuery(ds)])
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
.then(async (queries) => {
|
||||||
|
// we remove queries that have an invalid datasources
|
||||||
|
const validQueries = await removeQueriesWithInvalidDatasource(queries);
|
||||||
|
|
||||||
|
if (!validQueries.length && paneDatasource) {
|
||||||
|
// and in case there's no query left we add a default one.
|
||||||
|
return [
|
||||||
|
getDefaultQuery(
|
||||||
|
isMixedDatasource(paneDatasource) ? await getDatasourceSrv().get() : paneDatasource
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return validQueries;
|
||||||
|
})
|
||||||
|
.then((queries) => {
|
||||||
|
return dispatch(
|
||||||
|
initializeExplore({
|
||||||
|
exploreId: exploreId as ExploreId,
|
||||||
|
datasource: paneDatasource,
|
||||||
|
queries,
|
||||||
|
range,
|
||||||
|
panelsState,
|
||||||
|
})
|
||||||
|
).unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
).then((panes) => {
|
||||||
|
const urlState = panes.reduce<ExploreQueryParams>((acc, { exploreId, state }) => {
|
||||||
|
return { ...acc, [exploreId]: serializeStateToUrlParam(getUrlStateFromPaneState(state)) };
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
location.partial({ ...urlState, orgId }, true);
|
||||||
|
|
||||||
|
initState.current = 'done';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prevParams.current = {
|
||||||
|
left: params.left,
|
||||||
|
};
|
||||||
|
if (params.right) {
|
||||||
|
prevParams.current.right = params.right;
|
||||||
|
}
|
||||||
|
|
||||||
|
isURLOutOfSync && initState.current === 'done' && sync();
|
||||||
|
}, [params.left, params.right, dispatch, statePanes, exploreMixedDatasource, orgId, location]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultQuery(ds: DataSourceApi) {
|
||||||
|
return { ...ds.getDefaultQuery?.(CoreApp.Explore), refId: 'A', datasource: ds.getRef() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMixedDatasource(datasource: DataSourceApi) {
|
||||||
|
return datasource.name === MIXED_DATASOURCE_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueryFilter(datasource?: DataSourceApi) {
|
||||||
|
// if the root datasource is mixed, filter out queries that don't have a datasource.
|
||||||
|
if (datasource && isMixedDatasource(datasource)) {
|
||||||
|
return (q: DataQuery) => !!q.datasource;
|
||||||
|
} else {
|
||||||
|
// else filter out queries that have a datasource different from the root one.
|
||||||
|
// Queries may not have a datasource, if so, it's assumed they are using the root datasource
|
||||||
|
return (q: DataQuery) => {
|
||||||
|
if (!q.datasource) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Due to legacy URLs, `datasource` in queries may be a string. This logic should probably be in the migration
|
||||||
|
if (typeof q.datasource === 'string') {
|
||||||
|
return q.datasource === datasource?.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return q.datasource.uid === datasource?.uid;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeQueriesWithInvalidDatasource(queries: DataQuery[]) {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
queries.map((query) => {
|
||||||
|
return getDatasourceSrv()
|
||||||
|
.get(query.datasource)
|
||||||
|
.then((ds) => ({
|
||||||
|
query,
|
||||||
|
ds,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.filter(isFulfilled).map(({ value }) => value.query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the datasource that an explore pane should be using.
|
||||||
|
* If the URL specifies a datasource and that datasource exists, it will be used unless said datasource is mixed and `allowMixed` is false.
|
||||||
|
* Otherwise the datasource will be extracetd from the the first query specifying a valid datasource.
|
||||||
|
*
|
||||||
|
* If there's no datasource in the queries, the last used datasource will be used.
|
||||||
|
* if there's no last used datasource, the default datasource will be used.
|
||||||
|
*
|
||||||
|
* @param rootDatasource the top-level datasource specified in the URL
|
||||||
|
* @param queries the queries in the pane
|
||||||
|
* @param orgId the orgId of the user
|
||||||
|
* @param allowMixed whether mixed datasources are allowed
|
||||||
|
*
|
||||||
|
* @returns the datasource UID that the pane should use, undefined if no suitable datasource is found
|
||||||
|
*/
|
||||||
|
async function getPaneDatasource(
|
||||||
|
rootDatasource: DataSourceRef | string | null | undefined,
|
||||||
|
queries: DataQuery[],
|
||||||
|
orgId: number,
|
||||||
|
allowMixed: boolean
|
||||||
|
) {
|
||||||
|
// If there's a root datasource, use it unless it's mixed and we don't allow mixed.
|
||||||
|
if (rootDatasource) {
|
||||||
|
try {
|
||||||
|
const ds = await getDatasourceSrv().get(rootDatasource);
|
||||||
|
|
||||||
|
if (!isMixedDatasource(ds) || allowMixed) {
|
||||||
|
return ds;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: if queries have multiple datasources and allowMixed is true, we should return mixed datasource
|
||||||
|
// Else we try to find a datasource in the queries, returning the first one that exists
|
||||||
|
const queriesWithDS = queries.filter((q) => q.datasource);
|
||||||
|
for (const query of queriesWithDS) {
|
||||||
|
try {
|
||||||
|
return await getDatasourceSrv().get(query.datasource);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If none of the queries specify a avalid datasource, we use the last used one
|
||||||
|
const lastUsedDSUID = getLastUsedDatasourceUID(orgId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
getDatasourceSrv()
|
||||||
|
.get(lastUsedDSUID)
|
||||||
|
// Or the default one
|
||||||
|
.catch(() => getDatasourceSrv().get())
|
||||||
|
.catch(() => undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFulfilled = <T>(promise: PromiseSettledResult<T>): promise is PromiseFulfilledResult<T> =>
|
||||||
|
promise.status === 'fulfilled';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare 2 explore urls and return a map of what changed. Used to update the local state with all the
|
||||||
|
* side effects needed.
|
||||||
|
*/
|
||||||
|
const urlDiff = (
|
||||||
|
oldUrlState: ExploreUrlState | undefined,
|
||||||
|
currentUrlState: ExploreUrlState | undefined
|
||||||
|
): {
|
||||||
|
datasource: boolean;
|
||||||
|
queries: boolean;
|
||||||
|
range: boolean;
|
||||||
|
panelsState: boolean;
|
||||||
|
} => {
|
||||||
|
const datasource = !isEqual(currentUrlState?.datasource, oldUrlState?.datasource);
|
||||||
|
const queries = !isEqual(currentUrlState?.queries, oldUrlState?.queries);
|
||||||
|
const range = !isEqual(currentUrlState?.range || DEFAULT_RANGE, oldUrlState?.range || DEFAULT_RANGE);
|
||||||
|
const panelsState = !isEqual(currentUrlState?.panelsState, oldUrlState?.panelsState);
|
||||||
|
|
||||||
|
return {
|
||||||
|
datasource,
|
||||||
|
queries,
|
||||||
|
range,
|
||||||
|
panelsState,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function getUrlStateFromPaneState(pane: ExploreItemState): ExploreUrlState {
|
||||||
|
return {
|
||||||
|
// datasourceInstance should not be undefined anymore here but in case there is some path for it to be undefined
|
||||||
|
// lets just fallback instead of crashing.
|
||||||
|
datasource: pane.datasourceInstance?.uid || '',
|
||||||
|
queries: pane.queries.map(clearQueryKeys),
|
||||||
|
range: toRawTimeRange(pane.range),
|
||||||
|
// don't include panelsState in the url unless a piece of state is actually set
|
||||||
|
panelsState: pruneObject(pane.panelsState),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* recursively walks an object, removing keys where the value is undefined
|
||||||
|
* if the resulting object is empty, returns undefined
|
||||||
|
**/
|
||||||
|
function pruneObject(obj: object): object | undefined {
|
||||||
|
let pruned = mapValues(obj, (value) => (isObject(value) ? pruneObject(value) : value));
|
||||||
|
pruned = omitBy<typeof pruned>(pruned, isEmpty);
|
||||||
|
if (isEmpty(pruned)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return pruned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toRawTimeRange = (range: TimeRange): RawTimeRange => {
|
||||||
|
let from = range.raw.from;
|
||||||
|
if (isDateTime(from)) {
|
||||||
|
from = from.valueOf().toString(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
let to = range.raw.to;
|
||||||
|
if (isDateTime(to)) {
|
||||||
|
to = to.valueOf().toString(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
};
|
||||||
|
};
|
23
public/app/features/explore/hooks/useStopQueries.ts
Normal file
23
public/app/features/explore/hooks/useStopQueries.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { stopQueryState } from 'app/core/utils/explore';
|
||||||
|
import { useSelector } from 'app/types';
|
||||||
|
|
||||||
|
import { selectPanes } from '../state/selectors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from queries when unmounting.
|
||||||
|
* This avoids unnecessary state changes when navigating away from Explore.
|
||||||
|
*/
|
||||||
|
export function useStopQueries() {
|
||||||
|
const panesRef = useRef<ReturnType<typeof selectPanes>>({});
|
||||||
|
panesRef.current = useSelector(selectPanes);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
for (const [, pane] of Object.entries(panesRef.current)) {
|
||||||
|
stopQueryState(pane.querySubscription);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
24
public/app/features/explore/hooks/useTimeSrvFix.ts
Normal file
24
public/app/features/explore/hooks/useTimeSrvFix.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* timeSrv (which is used internally) on init reads `from` and `to` param from the URL and updates itself
|
||||||
|
* using those value regardless of what is passed to the init method.
|
||||||
|
* The updated value is then used by Explore to get the range for each pane.
|
||||||
|
* This means that if `from` and `to` parameters are present in the URL,
|
||||||
|
* it would be impossible to change the time range in Explore.
|
||||||
|
* We are only doing this on mount for 2 reasons:
|
||||||
|
* 1: Doing it on update means we'll enter a render loop.
|
||||||
|
* 2: when parsing time in Explore (before feeding it to timeSrv) we make sure `from` is before `to` inside
|
||||||
|
* each pane state in order to not trigger un URL update from timeSrv.
|
||||||
|
*/
|
||||||
|
export function useTimeSrvFix() {
|
||||||
|
const { location } = useGrafana();
|
||||||
|
useEffect(() => {
|
||||||
|
const searchParams = location.getSearchObject();
|
||||||
|
if (searchParams.from || searchParams.to) {
|
||||||
|
location.partial({ from: undefined, to: undefined }, true);
|
||||||
|
}
|
||||||
|
}, [location]);
|
||||||
|
}
|
@@ -1,7 +1,6 @@
|
|||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
import { serializeStateToUrlParam } from '@grafana/data';
|
import { serializeStateToUrlParam } from '@grafana/data';
|
||||||
import { locationService } from '@grafana/runtime';
|
|
||||||
|
|
||||||
import { changeDatasource } from './helper/interactions';
|
import { changeDatasource } from './helper/interactions';
|
||||||
import { makeLogsQueryResponse } from './helper/query';
|
import { makeLogsQueryResponse } from './helper/query';
|
||||||
@@ -18,20 +17,23 @@ describe('Explore: handle datasource states', () => {
|
|||||||
|
|
||||||
it('handles changing the datasource manually', async () => {
|
it('handles changing the datasource manually', async () => {
|
||||||
const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}', refId: 'A' }]) };
|
const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}', refId: 'A' }]) };
|
||||||
const { datasources } = setupExplore({ urlParams });
|
const { datasources, location } = setupExplore({ urlParams });
|
||||||
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||||
await waitForExplore();
|
await waitForExplore();
|
||||||
await changeDatasource('elastic');
|
await changeDatasource('elastic');
|
||||||
|
|
||||||
await screen.findByText('elastic Editor input:');
|
await screen.findByText('elastic Editor input:');
|
||||||
expect(datasources.elastic.query).not.toBeCalled();
|
expect(datasources.elastic.query).not.toBeCalled();
|
||||||
expect(locationService.getSearchObject()).toEqual({
|
|
||||||
orgId: '1',
|
await waitFor(async () => {
|
||||||
left: serializeStateToUrlParam({
|
expect(location.getSearchObject()).toEqual({
|
||||||
datasource: 'elastic-uid',
|
orgId: '1',
|
||||||
queries: [{ refId: 'A', datasource: { type: 'logs', uid: 'elastic-uid' } }],
|
left: serializeStateToUrlParam({
|
||||||
range: { from: 'now-1h', to: 'now' },
|
datasource: 'elastic-uid',
|
||||||
}),
|
queries: [{ refId: 'A', datasource: { type: 'logs', uid: 'elastic-uid' } }],
|
||||||
|
range: { from: 'now-1h', to: 'now' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,5 +1,8 @@
|
|||||||
import { render, screen, within } from '@testing-library/react';
|
import { waitFor, within } from '@testing-library/dom';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
import { fromPairs } from 'lodash';
|
import { fromPairs } from 'lodash';
|
||||||
|
import { stringify } from 'querystring';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { Route, Router } from 'react-router-dom';
|
import { Route, Router } from 'react-router-dom';
|
||||||
@@ -8,35 +11,40 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
|||||||
import {
|
import {
|
||||||
DataSourceApi,
|
DataSourceApi,
|
||||||
DataSourceInstanceSettings,
|
DataSourceInstanceSettings,
|
||||||
DataSourceRef,
|
|
||||||
QueryEditorProps,
|
QueryEditorProps,
|
||||||
ScopedVars,
|
DataSourcePluginMeta,
|
||||||
UrlQueryValue,
|
PluginType,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { locationSearchToObject, locationService, setDataSourceSrv, setEchoSrv, config } from '@grafana/runtime';
|
import {
|
||||||
|
setDataSourceSrv,
|
||||||
|
setEchoSrv,
|
||||||
|
config,
|
||||||
|
setLocationService,
|
||||||
|
HistoryWrapper,
|
||||||
|
LocationService,
|
||||||
|
} from '@grafana/runtime';
|
||||||
|
import { DataSourceRef } from '@grafana/schema';
|
||||||
import { GrafanaContext } from 'app/core/context/GrafanaContext';
|
import { GrafanaContext } from 'app/core/context/GrafanaContext';
|
||||||
import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute';
|
import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute';
|
||||||
import { Echo } from 'app/core/services/echo/Echo';
|
import { Echo } from 'app/core/services/echo/Echo';
|
||||||
import store from 'app/core/store';
|
import { setLastUsedDatasourceUID } from 'app/core/utils/explore';
|
||||||
import { lastUsedDatasourceKeyForOrgId } from 'app/core/utils/explore';
|
|
||||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||||
import { configureStore } from 'app/store/configureStore';
|
import { configureStore } from 'app/store/configureStore';
|
||||||
|
|
||||||
import { LokiDatasource } from '../../../../plugins/datasource/loki/datasource';
|
import { LokiDatasource } from '../../../../plugins/datasource/loki/datasource';
|
||||||
import { LokiQuery } from '../../../../plugins/datasource/loki/types';
|
import { LokiQuery } from '../../../../plugins/datasource/loki/types';
|
||||||
import { ExploreId } from '../../../../types';
|
import { ExploreId, ExploreQueryParams } from '../../../../types';
|
||||||
import { initialUserState } from '../../../profile/state/reducers';
|
import { initialUserState } from '../../../profile/state/reducers';
|
||||||
import ExplorePage from '../../ExplorePage';
|
import ExplorePage from '../../ExplorePage';
|
||||||
|
|
||||||
type DatasourceSetup = { settings: DataSourceInstanceSettings; api: DataSourceApi };
|
type DatasourceSetup = { settings: DataSourceInstanceSettings; api: DataSourceApi };
|
||||||
|
|
||||||
type SetupOptions = {
|
type SetupOptions = {
|
||||||
// default true
|
|
||||||
clearLocalStorage?: boolean;
|
clearLocalStorage?: boolean;
|
||||||
datasources?: DatasourceSetup[];
|
datasources?: DatasourceSetup[];
|
||||||
urlParams?: { left: string; right?: string } | string;
|
urlParams?: ExploreQueryParams & { [key: string]: string };
|
||||||
searchParams?: string;
|
|
||||||
prevUsedDatasource?: { orgId: number; datasource: string };
|
prevUsedDatasource?: { orgId: number; datasource: string };
|
||||||
|
mixedEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function setupExplore(options?: SetupOptions): {
|
export function setupExplore(options?: SetupOptions): {
|
||||||
@@ -44,6 +52,7 @@ export function setupExplore(options?: SetupOptions): {
|
|||||||
store: ReturnType<typeof configureStore>;
|
store: ReturnType<typeof configureStore>;
|
||||||
unmount: () => void;
|
unmount: () => void;
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
|
location: LocationService;
|
||||||
} {
|
} {
|
||||||
// Clear this up otherwise it persists data source selection
|
// Clear this up otherwise it persists data source selection
|
||||||
// TODO: probably add test for that too
|
// TODO: probably add test for that too
|
||||||
@@ -52,7 +61,7 @@ export function setupExplore(options?: SetupOptions): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options?.prevUsedDatasource) {
|
if (options?.prevUsedDatasource) {
|
||||||
store.set(lastUsedDatasourceKeyForOrgId(options?.prevUsedDatasource.orgId), options?.prevUsedDatasource.datasource);
|
setLastUsedDatasourceUID(options?.prevUsedDatasource.orgId, options?.prevUsedDatasource.datasource);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create this here so any mocks are recreated on setup and don't retain state
|
// Create this here so any mocks are recreated on setup and don't retain state
|
||||||
@@ -62,7 +71,7 @@ export function setupExplore(options?: SetupOptions): {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (config.featureToggles.exploreMixedDatasource) {
|
if (config.featureToggles.exploreMixedDatasource) {
|
||||||
defaultDatasources.push(makeDatasourceSetup({ name: MIXED_DATASOURCE_NAME, id: 999 }));
|
defaultDatasources.push(makeDatasourceSetup({ name: MIXED_DATASOURCE_NAME, uid: MIXED_DATASOURCE_NAME, id: 999 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const dsSettings = options?.datasources || defaultDatasources;
|
const dsSettings = options?.datasources || defaultDatasources;
|
||||||
@@ -71,24 +80,30 @@ export function setupExplore(options?: SetupOptions): {
|
|||||||
getList(): DataSourceInstanceSettings[] {
|
getList(): DataSourceInstanceSettings[] {
|
||||||
return dsSettings.map((d) => d.settings);
|
return dsSettings.map((d) => d.settings);
|
||||||
},
|
},
|
||||||
getInstanceSettings(ref: DataSourceRef) {
|
getInstanceSettings(ref?: DataSourceRef) {
|
||||||
return dsSettings.map((d) => d.settings).find((x) => x.name === ref || x.uid === ref || x.uid === ref.uid);
|
const allSettings = dsSettings.map((d) => d.settings);
|
||||||
|
return allSettings.find((x) => x.name === ref || x.uid === ref || x.uid === ref?.uid) || allSettings[0];
|
||||||
},
|
},
|
||||||
get(datasource?: string | DataSourceRef | null, scopedVars?: ScopedVars): Promise<DataSourceApi | undefined> {
|
get(datasource?: string | DataSourceRef | null): Promise<DataSourceApi> {
|
||||||
if (dsSettings.length === 0) {
|
let ds: DataSourceApi | undefined;
|
||||||
return Promise.resolve(undefined);
|
if (!datasource) {
|
||||||
|
ds = dsSettings[0]?.api;
|
||||||
} else {
|
} else {
|
||||||
const datasourceStr = typeof datasource === 'string';
|
ds = dsSettings.find((ds) =>
|
||||||
return Promise.resolve(
|
typeof datasource === 'string'
|
||||||
(datasource
|
? ds.api.name === datasource || ds.api.uid === datasource
|
||||||
? dsSettings.find((d) =>
|
: ds.api.uid === datasource?.uid
|
||||||
datasourceStr ? d.api.name === datasource || d.api.uid === datasource : d.api.uid === datasource?.uid
|
)?.api;
|
||||||
)
|
|
||||||
: dsSettings[0])!.api
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ds) {
|
||||||
|
return Promise.resolve(ds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject();
|
||||||
},
|
},
|
||||||
} as any);
|
reload() {},
|
||||||
|
});
|
||||||
|
|
||||||
setEchoSrv(new Echo());
|
setEchoSrv(new Echo());
|
||||||
|
|
||||||
@@ -109,43 +124,82 @@ export function setupExplore(options?: SetupOptions): {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
locationService.push({ pathname: '/explore', search: options?.searchParams });
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: [{ pathname: '/explore', search: stringify(options?.urlParams) }],
|
||||||
|
});
|
||||||
|
|
||||||
if (options?.urlParams) {
|
const location = new HistoryWrapper(history);
|
||||||
let urlParams: Record<string, string | UrlQueryValue> =
|
setLocationService(location);
|
||||||
typeof options.urlParams === 'string' ? locationSearchToObject(options.urlParams) : options.urlParams;
|
|
||||||
locationService.partial(urlParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = { component: ExplorePage };
|
const contextMock = getGrafanaContextMock({ location });
|
||||||
|
|
||||||
const { unmount, container } = render(
|
const { unmount, container } = render(
|
||||||
<Provider store={storeState}>
|
<Provider store={storeState}>
|
||||||
<GrafanaContext.Provider value={getGrafanaContextMock()}>
|
<GrafanaContext.Provider
|
||||||
<Router history={locationService.getHistory()}>
|
value={{
|
||||||
<Route path="/explore" exact render={(props) => <GrafanaRoute {...props} route={route as any} />} />
|
...contextMock,
|
||||||
|
config: {
|
||||||
|
...contextMock.config,
|
||||||
|
featureToggles: {
|
||||||
|
exploreMixedDatasource: options?.mixedEnabled ?? false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Router history={history}>
|
||||||
|
<Route
|
||||||
|
path="/explore"
|
||||||
|
exact
|
||||||
|
render={(props) => <GrafanaRoute {...props} route={{ component: ExplorePage, path: '/explore' }} />}
|
||||||
|
/>
|
||||||
</Router>
|
</Router>
|
||||||
</GrafanaContext.Provider>
|
</GrafanaContext.Provider>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
return { datasources: fromPairs(dsSettings.map((d) => [d.api.name, d.api])), store: storeState, unmount, container };
|
return {
|
||||||
|
datasources: fromPairs(dsSettings.map((d) => [d.api.name, d.api])),
|
||||||
|
store: storeState,
|
||||||
|
unmount,
|
||||||
|
container,
|
||||||
|
location,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: number } = {}): DatasourceSetup {
|
function makeDatasourceSetup({
|
||||||
const meta: any = {
|
name = 'loki',
|
||||||
|
id = 1,
|
||||||
|
uid: uidOverride,
|
||||||
|
}: { name?: string; id?: number; uid?: string } = {}): DatasourceSetup {
|
||||||
|
const uid = uidOverride || `${name}-uid`;
|
||||||
|
const type = 'logs';
|
||||||
|
|
||||||
|
const meta: DataSourcePluginMeta = {
|
||||||
info: {
|
info: {
|
||||||
|
author: {
|
||||||
|
name: 'Grafana',
|
||||||
|
},
|
||||||
|
description: '',
|
||||||
|
links: [],
|
||||||
|
screenshots: [],
|
||||||
|
updated: '',
|
||||||
|
version: '',
|
||||||
logos: {
|
logos: {
|
||||||
small: '',
|
small: '',
|
||||||
|
large: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
id: id.toString(),
|
id: id.toString(),
|
||||||
|
module: 'loki',
|
||||||
|
name,
|
||||||
|
type: PluginType.datasource,
|
||||||
|
baseUrl: '',
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
settings: {
|
settings: {
|
||||||
id,
|
id,
|
||||||
uid: `${name}-uid`,
|
uid,
|
||||||
type: 'logs',
|
type,
|
||||||
name,
|
name,
|
||||||
meta,
|
meta,
|
||||||
access: 'proxy',
|
access: 'proxy',
|
||||||
@@ -170,20 +224,19 @@ function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: nu
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
name: name,
|
name: name,
|
||||||
uid: `${name}-uid`,
|
uid: uid,
|
||||||
query: jest.fn(),
|
query: jest.fn(),
|
||||||
getRef: jest.fn().mockReturnValue({ type: 'logs', uid: `${name}-uid` }),
|
getRef: () => ({ type, uid }),
|
||||||
meta,
|
meta,
|
||||||
} as any,
|
} as any,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const waitForExplore = async (exploreId: ExploreId = ExploreId.left, multi = false) => {
|
export const waitForExplore = (exploreId: ExploreId = ExploreId.left) => {
|
||||||
if (multi) {
|
return waitFor(async () => {
|
||||||
return await withinExplore(exploreId).findAllByText(/Editor/i);
|
const container = screen.getAllByTestId('data-testid Explore');
|
||||||
} else {
|
return within(container[exploreId === ExploreId.left ? 0 : 1]);
|
||||||
return await withinExplore(exploreId).findByText(/Editor/i);
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tearDown = () => {
|
export const tearDown = () => {
|
||||||
|
@@ -47,6 +47,7 @@ jest.mock('@grafana/runtime', () => ({
|
|||||||
|
|
||||||
jest.mock('app/core/core', () => ({
|
jest.mock('app/core/core', () => ({
|
||||||
contextSrv: {
|
contextSrv: {
|
||||||
|
hasPermission: () => true,
|
||||||
hasAccess: () => true,
|
hasAccess: () => true,
|
||||||
isSignedIn: true,
|
isSignedIn: true,
|
||||||
},
|
},
|
||||||
@@ -92,7 +93,7 @@ describe('Explore: Query History', () => {
|
|||||||
it('adds new query history items after the query is run.', async () => {
|
it('adds new query history items after the query is run.', async () => {
|
||||||
// when Explore is opened
|
// when Explore is opened
|
||||||
const { datasources, unmount } = setupExplore();
|
const { datasources, unmount } = setupExplore();
|
||||||
(datasources.loki.query as jest.Mock).mockReturnValueOnce(makeLogsQueryResponse());
|
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||||
await waitForExplore();
|
await waitForExplore();
|
||||||
|
|
||||||
// and a user runs a query and opens query history
|
// and a user runs a query and opens query history
|
||||||
@@ -127,7 +128,7 @@ describe('Explore: Query History', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { datasources } = setupExplore({ urlParams });
|
const { datasources } = setupExplore({ urlParams });
|
||||||
(datasources.loki.query as jest.Mock).mockReturnValueOnce(makeLogsQueryResponse());
|
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||||
await waitForExplore();
|
await waitForExplore();
|
||||||
await openQueryHistory();
|
await openQueryHistory();
|
||||||
|
|
||||||
@@ -136,7 +137,7 @@ describe('Explore: Query History', () => {
|
|||||||
await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}']);
|
await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.skip('updates the state in both Explore panes', async () => {
|
it('updates the state in both Explore panes', async () => {
|
||||||
const urlParams = {
|
const urlParams = {
|
||||||
left: serializeStateToUrlParam({
|
left: serializeStateToUrlParam({
|
||||||
datasource: 'loki',
|
datasource: 'loki',
|
||||||
@@ -151,7 +152,7 @@ describe('Explore: Query History', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { datasources } = setupExplore({ urlParams });
|
const { datasources } = setupExplore({ urlParams });
|
||||||
(datasources.loki.query as jest.Mock).mockReturnValue(makeLogsQueryResponse());
|
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
|
||||||
await waitForExplore();
|
await waitForExplore();
|
||||||
await waitForExplore(ExploreId.right);
|
await waitForExplore(ExploreId.right);
|
||||||
|
|
||||||
@@ -188,7 +189,7 @@ describe('Explore: Query History', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { datasources } = setupExplore({ urlParams });
|
const { datasources } = setupExplore({ urlParams });
|
||||||
(datasources.loki.query as jest.Mock).mockReturnValueOnce(makeLogsQueryResponse());
|
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||||
await waitForExplore();
|
await waitForExplore();
|
||||||
await openQueryHistory();
|
await openQueryHistory();
|
||||||
await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.left);
|
await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.left);
|
||||||
@@ -222,7 +223,7 @@ describe('Explore: Query History', () => {
|
|||||||
it('pagination', async () => {
|
it('pagination', async () => {
|
||||||
config.queryHistoryEnabled = true;
|
config.queryHistoryEnabled = true;
|
||||||
const { datasources } = setupExplore();
|
const { datasources } = setupExplore();
|
||||||
(datasources.loki.query as jest.Mock).mockReturnValueOnce(makeLogsQueryResponse());
|
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||||
fetchMock.mockReturnValue(
|
fetchMock.mockReturnValue(
|
||||||
of({
|
of({
|
||||||
data: { result: { queryHistory: [{ datasourceUid: 'loki', queries: [{ expr: 'query' }] }], totalCount: 2 } },
|
data: { result: { queryHistory: [{ datasourceUid: 'loki', queries: [{ expr: 'query' }] }], totalCount: 2 } },
|
||||||
|
@@ -3,6 +3,7 @@ import { AnyAction, createAction } from '@reduxjs/toolkit';
|
|||||||
|
|
||||||
import { DataSourceApi, HistoryItem } from '@grafana/data';
|
import { DataSourceApi, HistoryItem } from '@grafana/data';
|
||||||
import { reportInteraction } from '@grafana/runtime';
|
import { reportInteraction } from '@grafana/runtime';
|
||||||
|
import { DataSourceRef } from '@grafana/schema';
|
||||||
import { RefreshPicker } from '@grafana/ui';
|
import { RefreshPicker } from '@grafana/ui';
|
||||||
import { stopQueryState } from 'app/core/utils/explore';
|
import { stopQueryState } from 'app/core/utils/explore';
|
||||||
import { ExploreItemState, ThunkResult } from 'app/types';
|
import { ExploreItemState, ThunkResult } from 'app/types';
|
||||||
@@ -39,12 +40,12 @@ export const updateDatasourceInstanceAction = createAction<UpdateDatasourceInsta
|
|||||||
*/
|
*/
|
||||||
export function changeDatasource(
|
export function changeDatasource(
|
||||||
exploreId: ExploreId,
|
exploreId: ExploreId,
|
||||||
datasourceUid: string,
|
datasource: string | DataSourceRef,
|
||||||
options?: { importQueries: boolean }
|
options?: { importQueries: boolean }
|
||||||
): ThunkResult<Promise<void>> {
|
): ThunkResult<Promise<void>> {
|
||||||
return async (dispatch, getState) => {
|
return async (dispatch, getState) => {
|
||||||
const orgId = getState().user.orgId;
|
const orgId = getState().user.orgId;
|
||||||
const { history, instance } = await loadAndInitDatasource(orgId, { uid: datasourceUid });
|
const { history, instance } = await loadAndInitDatasource(orgId, datasource);
|
||||||
const currentDataSourceInstance = getState().explore.panes[exploreId]!.datasourceInstance;
|
const currentDataSourceInstance = getState().explore.panes[exploreId]!.datasourceInstance;
|
||||||
|
|
||||||
reportInteraction('explore_change_ds', {
|
reportInteraction('explore_change_ds', {
|
||||||
@@ -66,12 +67,12 @@ export function changeDatasource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (getState().explore.panes[exploreId]!.isLive) {
|
if (getState().explore.panes[exploreId]!.isLive) {
|
||||||
dispatch(changeRefreshInterval(exploreId, RefreshPicker.offOption.value));
|
dispatch(changeRefreshInterval({ exploreId, refreshInterval: RefreshPicker.offOption.value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exception - we only want to run queries on data source change, if the queries were imported
|
// Exception - we only want to run queries on data source change, if the queries were imported
|
||||||
if (options?.importQueries) {
|
if (options?.importQueries) {
|
||||||
dispatch(runQueries(exploreId));
|
dispatch(runQueries({ exploreId }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -106,7 +107,6 @@ export const datasourceReducer = (state: ExploreItemState, action: AnyAction): E
|
|||||||
loading: false,
|
loading: false,
|
||||||
queryKeys: [],
|
queryKeys: [],
|
||||||
history,
|
history,
|
||||||
datasourceMissing: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,135 +0,0 @@
|
|||||||
import { of } from 'rxjs';
|
|
||||||
|
|
||||||
import { serializeStateToUrlParam } from '@grafana/data';
|
|
||||||
import { setDataSourceSrv } from '@grafana/runtime';
|
|
||||||
import { ExploreId, StoreState, ThunkDispatch } from 'app/types';
|
|
||||||
|
|
||||||
import { configureStore } from '../../../store/configureStore';
|
|
||||||
|
|
||||||
import { refreshExplore } from './explorePane';
|
|
||||||
import { createDefaultInitialState } from './helpers';
|
|
||||||
|
|
||||||
jest.mock('../../dashboard/services/TimeSrv', () => ({
|
|
||||||
getTimeSrv: jest.fn().mockReturnValue({
|
|
||||||
init: jest.fn(),
|
|
||||||
timeRange: jest.fn().mockReturnValue({}),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { testRange, defaultInitialState } = createDefaultInitialState();
|
|
||||||
|
|
||||||
jest.mock('@grafana/runtime', () => ({
|
|
||||||
...jest.requireActual('@grafana/runtime'),
|
|
||||||
getTemplateSrv: () => ({
|
|
||||||
updateTimeRange: jest.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function setupStore(state?: any) {
|
|
||||||
return configureStore({
|
|
||||||
...defaultInitialState,
|
|
||||||
explore: {
|
|
||||||
panes: {
|
|
||||||
[ExploreId.left]: {
|
|
||||||
...defaultInitialState.explore.panes.left,
|
|
||||||
...(state || {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setup(state?: any) {
|
|
||||||
const datasources: Record<string, any> = {
|
|
||||||
newDs: {
|
|
||||||
testDatasource: jest.fn(),
|
|
||||||
init: jest.fn(),
|
|
||||||
query: jest.fn(),
|
|
||||||
name: 'newDs',
|
|
||||||
meta: { id: 'newDs' },
|
|
||||||
getRef: () => ({ uid: 'newDs' }),
|
|
||||||
},
|
|
||||||
someDs: {
|
|
||||||
testDatasource: jest.fn(),
|
|
||||||
init: jest.fn(),
|
|
||||||
query: jest.fn(),
|
|
||||||
name: 'someDs',
|
|
||||||
meta: { id: 'someDs' },
|
|
||||||
getRef: () => ({ uid: 'someDs' }),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
setDataSourceSrv({
|
|
||||||
getList() {
|
|
||||||
return Object.values(datasources).map((d) => ({ name: d.name }));
|
|
||||||
},
|
|
||||||
getInstanceSettings(name: string) {
|
|
||||||
return { name, getRef: () => ({ uid: name }) };
|
|
||||||
},
|
|
||||||
get(name?: string) {
|
|
||||||
return Promise.resolve(
|
|
||||||
name
|
|
||||||
? datasources[name]
|
|
||||||
: {
|
|
||||||
testDatasource: jest.fn(),
|
|
||||||
init: jest.fn(),
|
|
||||||
name: 'default',
|
|
||||||
getRef() {
|
|
||||||
return { type: 'default', uid: 'default' };
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const { dispatch, getState }: { dispatch: ThunkDispatch; getState: () => StoreState } = setupStore({
|
|
||||||
datasourceInstance: datasources.someDs,
|
|
||||||
...(state || {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
dispatch,
|
|
||||||
getState,
|
|
||||||
datasources,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('refreshExplore', () => {
|
|
||||||
it('should change data source when datasource in url changes', async () => {
|
|
||||||
const { dispatch, getState } = setup();
|
|
||||||
await dispatch(
|
|
||||||
refreshExplore(ExploreId.left, serializeStateToUrlParam({ datasource: 'newDs', queries: [], range: testRange }))
|
|
||||||
);
|
|
||||||
expect(getState().explore.panes.left!.datasourceInstance?.name).toBe('newDs');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should change and run new queries from the URL', async () => {
|
|
||||||
const { dispatch, getState, datasources } = setup();
|
|
||||||
datasources.someDs.query.mockReturnValueOnce(of({}));
|
|
||||||
await dispatch(
|
|
||||||
refreshExplore(
|
|
||||||
ExploreId.left,
|
|
||||||
serializeStateToUrlParam({ datasource: 'someDs', queries: [{ expr: 'count()', refId: 'A' }], range: testRange })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
// same
|
|
||||||
const state = getState().explore.panes.left!;
|
|
||||||
expect(state.datasourceInstance?.name).toBe('someDs');
|
|
||||||
expect(state.queries.length).toBe(1);
|
|
||||||
expect(state.queries).toMatchObject([{ expr: 'count()' }]);
|
|
||||||
expect(datasources.someDs.query).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not do anything if pane is not present', async () => {
|
|
||||||
const { dispatch, getState } = setup({});
|
|
||||||
const state = getState();
|
|
||||||
await dispatch(
|
|
||||||
refreshExplore(
|
|
||||||
ExploreId.right,
|
|
||||||
serializeStateToUrlParam({ datasource: 'newDs', queries: [{ expr: 'count()', refId: 'A' }], range: testRange })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state).toEqual(getState());
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,41 +1,26 @@
|
|||||||
import { createAction, PayloadAction } from '@reduxjs/toolkit';
|
import { createAction, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { isEqual } from 'lodash';
|
|
||||||
import { AnyAction } from 'redux';
|
import { AnyAction } from 'redux';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EventBusExtended,
|
|
||||||
ExploreUrlState,
|
|
||||||
TimeRange,
|
TimeRange,
|
||||||
HistoryItem,
|
HistoryItem,
|
||||||
DataSourceApi,
|
DataSourceApi,
|
||||||
ExplorePanelsState,
|
ExplorePanelsState,
|
||||||
PreferredVisualisationType,
|
PreferredVisualisationType,
|
||||||
|
RawTimeRange,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { getDataSourceSrv } from '@grafana/runtime';
|
|
||||||
import { DataQuery, DataSourceRef } from '@grafana/schema';
|
import { DataQuery, DataSourceRef } from '@grafana/schema';
|
||||||
import {
|
import { getQueryKeys } from 'app/core/utils/explore';
|
||||||
DEFAULT_RANGE,
|
import { getTimeZone } from 'app/features/profile/state/selectors';
|
||||||
getQueryKeys,
|
|
||||||
parseUrlState,
|
|
||||||
ensureQueries,
|
|
||||||
generateNewKeyAndAddRefIdIfMissing,
|
|
||||||
getTimeRangeFromUrl,
|
|
||||||
} from 'app/core/utils/explore';
|
|
||||||
import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors';
|
|
||||||
import { createAsyncThunk, ThunkResult } from 'app/types';
|
import { createAsyncThunk, ThunkResult } from 'app/types';
|
||||||
import { ExploreId, ExploreItemState } from 'app/types/explore';
|
import { ExploreId, ExploreItemState } from 'app/types/explore';
|
||||||
|
|
||||||
import { datasourceReducer } from './datasource';
|
import { datasourceReducer } from './datasource';
|
||||||
import { historyReducer } from './history';
|
import { historyReducer } from './history';
|
||||||
import { richHistorySearchFiltersUpdatedAction, richHistoryUpdatedAction, stateSave } from './main';
|
import { richHistorySearchFiltersUpdatedAction, richHistoryUpdatedAction } from './main';
|
||||||
import { queryReducer, runQueries, setQueriesAction } from './query';
|
import { queryReducer, runQueries } from './query';
|
||||||
import { timeReducer, updateTime } from './time';
|
import { timeReducer, updateTime } from './time';
|
||||||
import {
|
import { makeExplorePaneState, loadAndInitDatasource, createEmptyQueryResponse, getRange } from './utils';
|
||||||
makeExplorePaneState,
|
|
||||||
loadAndInitDatasource,
|
|
||||||
createEmptyQueryResponse,
|
|
||||||
getUrlStateFromPaneState,
|
|
||||||
} from './utils';
|
|
||||||
// Types
|
// Types
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -49,7 +34,6 @@ import {
|
|||||||
export interface ChangeSizePayload {
|
export interface ChangeSizePayload {
|
||||||
exploreId: ExploreId;
|
exploreId: ExploreId;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
|
||||||
}
|
}
|
||||||
export const changeSizeAction = createAction<ChangeSizePayload>('explore/changeSize');
|
export const changeSizeAction = createAction<ChangeSizePayload>('explore/changeSize');
|
||||||
|
|
||||||
@@ -81,7 +65,6 @@ export function changePanelState(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
dispatch(stateSave());
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,13 +74,10 @@ export function changePanelState(
|
|||||||
*/
|
*/
|
||||||
interface InitializeExplorePayload {
|
interface InitializeExplorePayload {
|
||||||
exploreId: ExploreId;
|
exploreId: ExploreId;
|
||||||
containerWidth: number;
|
|
||||||
eventBridge: EventBusExtended;
|
|
||||||
queries: DataQuery[];
|
queries: DataQuery[];
|
||||||
range: TimeRange;
|
range: TimeRange;
|
||||||
history: HistoryItem[];
|
history: HistoryItem[];
|
||||||
datasourceInstance?: DataSourceApi;
|
datasourceInstance?: DataSourceApi;
|
||||||
isFromCompactUrl?: boolean;
|
|
||||||
}
|
}
|
||||||
const initializeExploreAction = createAction<InitializeExplorePayload>('explore/initializeExploreAction');
|
const initializeExploreAction = createAction<InitializeExplorePayload>('explore/initializeExploreAction');
|
||||||
|
|
||||||
@@ -110,22 +90,16 @@ export const setUrlReplacedAction = createAction<SetUrlReplacedPayload>('explore
|
|||||||
* Keep track of the Explore container size, in particular the width.
|
* Keep track of the Explore container size, in particular the width.
|
||||||
* The width will be used to calculate graph intervals (number of datapoints).
|
* The width will be used to calculate graph intervals (number of datapoints).
|
||||||
*/
|
*/
|
||||||
export function changeSize(
|
export function changeSize(exploreId: ExploreId, { width }: { width: number }): PayloadAction<ChangeSizePayload> {
|
||||||
exploreId: ExploreId,
|
return changeSizeAction({ exploreId, width });
|
||||||
{ height, width }: { height: number; width: number }
|
|
||||||
): PayloadAction<ChangeSizePayload> {
|
|
||||||
return changeSizeAction({ exploreId, height, width });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InitializeExploreOptions {
|
interface InitializeExploreOptions {
|
||||||
exploreId: ExploreId;
|
exploreId: ExploreId;
|
||||||
datasource: DataSourceRef | string;
|
datasource: DataSourceRef | string | undefined;
|
||||||
queries: DataQuery[];
|
queries: DataQuery[];
|
||||||
range: TimeRange;
|
range: RawTimeRange;
|
||||||
containerWidth: number;
|
|
||||||
eventBridge: EventBusExtended;
|
|
||||||
panelsState?: ExplorePanelsState;
|
panelsState?: ExplorePanelsState;
|
||||||
isFromCompactUrl?: boolean;
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Initialize Explore state with state from the URL and the React component.
|
* Initialize Explore state with state from the URL and the React component.
|
||||||
@@ -138,23 +112,13 @@ interface InitializeExploreOptions {
|
|||||||
export const initializeExplore = createAsyncThunk(
|
export const initializeExplore = createAsyncThunk(
|
||||||
'explore/initializeExplore',
|
'explore/initializeExplore',
|
||||||
async (
|
async (
|
||||||
{
|
{ exploreId, datasource, queries, range, panelsState }: InitializeExploreOptions,
|
||||||
exploreId,
|
{ dispatch, getState, fulfillWithValue }
|
||||||
datasource,
|
|
||||||
queries,
|
|
||||||
range,
|
|
||||||
containerWidth,
|
|
||||||
eventBridge,
|
|
||||||
panelsState,
|
|
||||||
isFromCompactUrl,
|
|
||||||
}: InitializeExploreOptions,
|
|
||||||
{ dispatch, getState }
|
|
||||||
) => {
|
) => {
|
||||||
const exploreDatasources = getDataSourceSrv().getList();
|
|
||||||
let instance = undefined;
|
let instance = undefined;
|
||||||
let history: HistoryItem[] = [];
|
let history: HistoryItem[] = [];
|
||||||
|
|
||||||
if (exploreDatasources.length >= 1) {
|
if (datasource) {
|
||||||
const orgId = getState().user.orgId;
|
const orgId = getState().user.orgId;
|
||||||
const loadResult = await loadAndInitDatasource(orgId, datasource);
|
const loadResult = await loadAndInitDatasource(orgId, datasource);
|
||||||
instance = loadResult.instance;
|
instance = loadResult.instance;
|
||||||
@@ -164,13 +128,10 @@ export const initializeExplore = createAsyncThunk(
|
|||||||
dispatch(
|
dispatch(
|
||||||
initializeExploreAction({
|
initializeExploreAction({
|
||||||
exploreId,
|
exploreId,
|
||||||
containerWidth,
|
|
||||||
eventBridge,
|
|
||||||
queries,
|
queries,
|
||||||
range,
|
range: getRange(range, getTimeZone(getState().user)),
|
||||||
datasourceInstance: instance,
|
datasourceInstance: instance,
|
||||||
history,
|
history,
|
||||||
isFromCompactUrl,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (panelsState !== undefined) {
|
if (panelsState !== undefined) {
|
||||||
@@ -179,81 +140,13 @@ export const initializeExplore = createAsyncThunk(
|
|||||||
dispatch(updateTime({ exploreId }));
|
dispatch(updateTime({ exploreId }));
|
||||||
|
|
||||||
if (instance) {
|
if (instance) {
|
||||||
// We do not want to add the url to browser history on init because when the pane is initialised it's because
|
dispatch(runQueries({ exploreId }));
|
||||||
// we already have something in the url. Adding basically the same state as additional history item prevents
|
|
||||||
// user to go back to previous url.
|
|
||||||
dispatch(runQueries(exploreId, { replaceUrl: true }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return fulfillWithValue({ exploreId, state: getState().explore.panes[exploreId]! });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Reacts to changes in URL state that we need to sync back to our redux state. Computes diff of newUrlQuery vs current
|
|
||||||
* state and runs update actions for relevant parts.
|
|
||||||
*/
|
|
||||||
export function refreshExplore(exploreId: ExploreId, newUrlQuery: string): ThunkResult<void> {
|
|
||||||
return async (dispatch, getState) => {
|
|
||||||
const itemState = getState().explore.panes[exploreId];
|
|
||||||
if (!itemState) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get diff of what should be updated
|
|
||||||
const newUrlState = parseUrlState(newUrlQuery);
|
|
||||||
const update = urlDiff(newUrlState, getUrlStateFromPaneState(itemState));
|
|
||||||
|
|
||||||
const { containerWidth, eventBridge } = itemState;
|
|
||||||
|
|
||||||
// datasource will either be name or UID here
|
|
||||||
const { datasource, queries, range: urlRange, panelsState } = newUrlState;
|
|
||||||
const refreshQueries: DataQuery[] = [];
|
|
||||||
|
|
||||||
for (let index = 0; index < queries.length; index++) {
|
|
||||||
const query = queries[index];
|
|
||||||
refreshQueries.push(generateNewKeyAndAddRefIdIfMissing(query, refreshQueries, index));
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeZone = getTimeZone(getState().user);
|
|
||||||
const fiscalYearStartMonth = getFiscalYearStartMonth(getState().user);
|
|
||||||
const range = getTimeRangeFromUrl(urlRange, timeZone, fiscalYearStartMonth);
|
|
||||||
|
|
||||||
// commit changes based on the diff of new url vs old url
|
|
||||||
|
|
||||||
if (update.datasource) {
|
|
||||||
const initialQueries = await ensureQueries(queries);
|
|
||||||
await dispatch(
|
|
||||||
initializeExplore({
|
|
||||||
exploreId,
|
|
||||||
datasource,
|
|
||||||
queries: initialQueries,
|
|
||||||
range,
|
|
||||||
containerWidth,
|
|
||||||
eventBridge,
|
|
||||||
panelsState,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (update.range) {
|
|
||||||
dispatch(updateTime({ exploreId, rawRange: range.raw }));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (update.queries) {
|
|
||||||
dispatch(setQueriesAction({ exploreId, queries: refreshQueries }));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (update.panelsState && panelsState !== undefined) {
|
|
||||||
dispatch(changePanelsStateAction({ exploreId, panelsState }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// always run queries when refresh is needed
|
|
||||||
if (update.queries || update.range) {
|
|
||||||
dispatch(runQueries(exploreId));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reducer for an Explore area, to be used by the global Explore reducer.
|
* Reducer for an Explore area, to be used by the global Explore reducer.
|
||||||
*/
|
*/
|
||||||
@@ -296,51 +189,20 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (initializeExploreAction.match(action)) {
|
if (initializeExploreAction.match(action)) {
|
||||||
const { containerWidth, eventBridge, queries, range, datasourceInstance, history, isFromCompactUrl } =
|
const { queries, range, datasourceInstance, history } = action.payload;
|
||||||
action.payload;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
containerWidth,
|
|
||||||
eventBridge,
|
|
||||||
range,
|
range,
|
||||||
queries,
|
queries,
|
||||||
initialized: true,
|
initialized: true,
|
||||||
queryKeys: getQueryKeys(queries),
|
queryKeys: getQueryKeys(queries),
|
||||||
datasourceInstance,
|
datasourceInstance,
|
||||||
history,
|
history,
|
||||||
datasourceMissing: !datasourceInstance,
|
|
||||||
queryResponse: createEmptyQueryResponse(),
|
queryResponse: createEmptyQueryResponse(),
|
||||||
cache: [],
|
cache: [],
|
||||||
isFromCompactUrl: isFromCompactUrl || false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare 2 explore urls and return a map of what changed. Used to update the local state with all the
|
|
||||||
* side effects needed.
|
|
||||||
*/
|
|
||||||
export const urlDiff = (
|
|
||||||
oldUrlState: ExploreUrlState | undefined,
|
|
||||||
currentUrlState: ExploreUrlState | undefined
|
|
||||||
): {
|
|
||||||
datasource: boolean;
|
|
||||||
queries: boolean;
|
|
||||||
range: boolean;
|
|
||||||
panelsState: boolean;
|
|
||||||
} => {
|
|
||||||
const datasource = !isEqual(currentUrlState?.datasource, oldUrlState?.datasource);
|
|
||||||
const queries = !isEqual(currentUrlState?.queries, oldUrlState?.queries);
|
|
||||||
const range = !isEqual(currentUrlState?.range || DEFAULT_RANGE, oldUrlState?.range || DEFAULT_RANGE);
|
|
||||||
const panelsState = !isEqual(currentUrlState?.panelsState, oldUrlState?.panelsState);
|
|
||||||
|
|
||||||
return {
|
|
||||||
datasource,
|
|
||||||
queries,
|
|
||||||
range,
|
|
||||||
panelsState,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
@@ -9,7 +9,7 @@ import { reducerTester } from '../../../../test/core/redux/reducerTester';
|
|||||||
import { MockDataSourceApi } from '../../../../test/mocks/datasource_srv';
|
import { MockDataSourceApi } from '../../../../test/mocks/datasource_srv';
|
||||||
import { ExploreId, ExploreItemState, ExploreState } from '../../../types';
|
import { ExploreId, ExploreItemState, ExploreState } from '../../../types';
|
||||||
|
|
||||||
import { exploreReducer, navigateToExplore, splitCloseAction } from './main';
|
import { exploreReducer, navigateToExplore, splitClose } from './main';
|
||||||
|
|
||||||
const getNavigateToExploreContext = async (openInNewWindow?: (url: string) => void) => {
|
const getNavigateToExploreContext = async (openInNewWindow?: (url: string) => void) => {
|
||||||
const url = '/explore';
|
const url = '/explore';
|
||||||
@@ -136,7 +136,7 @@ describe('Explore reducer', () => {
|
|||||||
// closing left item
|
// closing left item
|
||||||
reducerTester<ExploreState>()
|
reducerTester<ExploreState>()
|
||||||
.givenReducer(exploreReducer, initialState)
|
.givenReducer(exploreReducer, initialState)
|
||||||
.whenActionIsDispatched(splitCloseAction({ itemId: ExploreId.left }))
|
.whenActionIsDispatched(splitClose(ExploreId.left))
|
||||||
.thenStateShouldEqual({
|
.thenStateShouldEqual({
|
||||||
evenSplitPanes: true,
|
evenSplitPanes: true,
|
||||||
largerExploreId: undefined,
|
largerExploreId: undefined,
|
||||||
@@ -166,7 +166,7 @@ describe('Explore reducer', () => {
|
|||||||
// closing left item
|
// closing left item
|
||||||
reducerTester<ExploreState>()
|
reducerTester<ExploreState>()
|
||||||
.givenReducer(exploreReducer, initialState)
|
.givenReducer(exploreReducer, initialState)
|
||||||
.whenActionIsDispatched(splitCloseAction({ itemId: ExploreId.right }))
|
.whenActionIsDispatched(splitClose(ExploreId.right))
|
||||||
.thenStateShouldEqual({
|
.thenStateShouldEqual({
|
||||||
evenSplitPanes: true,
|
evenSplitPanes: true,
|
||||||
largerExploreId: undefined,
|
largerExploreId: undefined,
|
||||||
@@ -193,7 +193,7 @@ describe('Explore reducer', () => {
|
|||||||
|
|
||||||
reducerTester<ExploreState>()
|
reducerTester<ExploreState>()
|
||||||
.givenReducer(exploreReducer, initialState)
|
.givenReducer(exploreReducer, initialState)
|
||||||
.whenActionIsDispatched(splitCloseAction({ itemId: ExploreId.right }))
|
.whenActionIsDispatched(splitClose(ExploreId.right))
|
||||||
.thenStateShouldEqual({
|
.thenStateShouldEqual({
|
||||||
evenSplitPanes: true,
|
evenSplitPanes: true,
|
||||||
panes: {
|
panes: {
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
import { createAction } from '@reduxjs/toolkit';
|
import { createAction } from '@reduxjs/toolkit';
|
||||||
import { AnyAction } from 'redux';
|
import { AnyAction } from 'redux';
|
||||||
|
|
||||||
import { ExploreUrlState, serializeStateToUrlParam, SplitOpenOptions, UrlQueryMap } from '@grafana/data';
|
import { SplitOpenOptions } from '@grafana/data';
|
||||||
import { DataSourceSrv, locationService } from '@grafana/runtime';
|
import { DataSourceSrv, locationService } from '@grafana/runtime';
|
||||||
import { GetExploreUrlArguments, stopQueryState } from 'app/core/utils/explore';
|
import { GetExploreUrlArguments } from 'app/core/utils/explore';
|
||||||
import { PanelModel } from 'app/features/dashboard/state';
|
import { PanelModel } from 'app/features/dashboard/state';
|
||||||
import { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore';
|
import { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore';
|
||||||
|
|
||||||
@@ -12,9 +12,10 @@ import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/uti
|
|||||||
import { createAsyncThunk, ThunkResult } from '../../../types';
|
import { createAsyncThunk, ThunkResult } from '../../../types';
|
||||||
import { CorrelationData } from '../../correlations/useCorrelations';
|
import { CorrelationData } from '../../correlations/useCorrelations';
|
||||||
import { TimeSrv } from '../../dashboard/services/TimeSrv';
|
import { TimeSrv } from '../../dashboard/services/TimeSrv';
|
||||||
|
import { withUniqueRefIds } from '../utils/queries';
|
||||||
|
|
||||||
import { initializeExplore, paneReducer } from './explorePane';
|
import { initializeExplore, paneReducer } from './explorePane';
|
||||||
import { getUrlStateFromPaneState, makeExplorePaneState } from './utils';
|
import { DEFAULT_RANGE, makeExplorePaneState } from './utils';
|
||||||
|
|
||||||
//
|
//
|
||||||
// Actions and Payloads
|
// Actions and Payloads
|
||||||
@@ -50,90 +51,43 @@ export const maximizePaneAction = createAction<{
|
|||||||
export const evenPaneResizeAction = createAction('explore/evenPaneResizeAction');
|
export const evenPaneResizeAction = createAction('explore/evenPaneResizeAction');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets state for explore.
|
* Close the pane with the given id.
|
||||||
*/
|
*/
|
||||||
export const resetExploreAction = createAction('explore/resetExplore');
|
type SplitCloseActionPayload = ExploreId;
|
||||||
|
export const splitClose = createAction<SplitCloseActionPayload>('explore/splitClose');
|
||||||
|
|
||||||
/**
|
export interface SetPaneStateActionPayload {
|
||||||
* Close the split view and save URL state.
|
[itemId: string]: Partial<ExploreItemState>;
|
||||||
*/
|
|
||||||
export interface SplitCloseActionPayload {
|
|
||||||
itemId: ExploreId;
|
|
||||||
}
|
}
|
||||||
export const splitCloseAction = createAction<SplitCloseActionPayload>('explore/splitClose');
|
export const setPaneState = createAction<SetPaneStateActionPayload>('explore/setPaneState');
|
||||||
|
|
||||||
//
|
export const clearPanes = createAction('explore/clearPanes');
|
||||||
// Action creators
|
|
||||||
//
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save local redux state back to the URL. Should be called when there is some change that should affect the URL.
|
* Opens a new split pane. It either copies existing state of the left pane
|
||||||
* Not all of the redux state is reflected in URL though.
|
* or uses values from options arg.
|
||||||
*/
|
*
|
||||||
export const stateSave = (options?: { replace?: boolean }): ThunkResult<void> => {
|
* TODO: this can be improved by better inferring fallback values.
|
||||||
return (_, getState) => {
|
|
||||||
const { left, right } = getState().explore.panes;
|
|
||||||
const orgId = getState().user.orgId.toString();
|
|
||||||
const urlStates: { [index: string]: string | null } = { orgId };
|
|
||||||
|
|
||||||
urlStates.left = serializeStateToUrlParam(getUrlStateFromPaneState(left!));
|
|
||||||
|
|
||||||
if (right) {
|
|
||||||
urlStates.right = serializeStateToUrlParam(getUrlStateFromPaneState(right));
|
|
||||||
} else {
|
|
||||||
urlStates.right = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastSavedUrl.right = urlStates.right;
|
|
||||||
lastSavedUrl.left = urlStates.left;
|
|
||||||
|
|
||||||
locationService.partial({ ...urlStates }, options?.replace);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Store the url we saved last se we are not trying to update local state based on that.
|
|
||||||
export const lastSavedUrl: UrlQueryMap = {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens a new right split pane by navigating to appropriate URL. It either copies existing state of the left pane
|
|
||||||
* or uses values from options arg. This does only navigation each pane is then responsible for initialization from
|
|
||||||
* the URL.
|
|
||||||
*/
|
*/
|
||||||
export const splitOpen = createAsyncThunk(
|
export const splitOpen = createAsyncThunk(
|
||||||
'explore/splitOpen',
|
'explore/splitOpen',
|
||||||
async (options: SplitOpenOptions | undefined, { getState }) => {
|
async (options: SplitOpenOptions | undefined, { getState, dispatch }) => {
|
||||||
const leftState: ExploreItemState = getState().explore.panes.left!;
|
const leftState = getState().explore.panes.left;
|
||||||
const leftUrlState = getUrlStateFromPaneState(leftState);
|
|
||||||
let rightUrlState: ExploreUrlState = leftUrlState;
|
|
||||||
|
|
||||||
if (options) {
|
const queries = options?.queries ?? (options?.query ? [options?.query] : leftState?.queries || []);
|
||||||
const { query, queries } = options;
|
|
||||||
|
|
||||||
rightUrlState = {
|
await dispatch(
|
||||||
datasource: options.datasourceUid,
|
initializeExplore({
|
||||||
queries: queries ?? (query ? [query] : []),
|
exploreId: ExploreId.right,
|
||||||
range: options.range || leftState.range,
|
datasource: options?.datasourceUid || leftState?.datasourceInstance?.getRef(),
|
||||||
panelsState: options.panelsState,
|
queries: withUniqueRefIds(queries),
|
||||||
};
|
range: options?.range || leftState?.range.raw || DEFAULT_RANGE,
|
||||||
}
|
panelsState: options?.panelsState || leftState?.panelsState,
|
||||||
|
})
|
||||||
const urlState = serializeStateToUrlParam(rightUrlState);
|
);
|
||||||
locationService.partial({ right: urlState }, true);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the split view and save URL state. We need to update the state here because when closing we cannot just
|
|
||||||
* update the URL and let the components handle it because if we swap panes from right to left it is not easily apparent
|
|
||||||
* from the URL.
|
|
||||||
*/
|
|
||||||
export function splitClose(itemId: ExploreId): ThunkResult<void> {
|
|
||||||
return (dispatch, getState) => {
|
|
||||||
dispatch(splitCloseAction({ itemId }));
|
|
||||||
dispatch(stateSave());
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NavigateToExploreDependencies {
|
export interface NavigateToExploreDependencies {
|
||||||
getDataSourceSrv: () => DataSourceSrv;
|
getDataSourceSrv: () => DataSourceSrv;
|
||||||
getTimeSrv: () => TimeSrv;
|
getTimeSrv: () => TimeSrv;
|
||||||
@@ -169,9 +123,7 @@ export const navigateToExplore = (
|
|||||||
const initialExploreItemState = makeExplorePaneState();
|
const initialExploreItemState = makeExplorePaneState();
|
||||||
export const initialExploreState: ExploreState = {
|
export const initialExploreState: ExploreState = {
|
||||||
syncedTimes: false,
|
syncedTimes: false,
|
||||||
panes: {
|
panes: {},
|
||||||
[ExploreId.left]: initialExploreItemState,
|
|
||||||
},
|
|
||||||
correlations: undefined,
|
correlations: undefined,
|
||||||
richHistoryStorageFull: false,
|
richHistoryStorageFull: false,
|
||||||
richHistoryLimitExceededWarningShown: false,
|
richHistoryLimitExceededWarningShown: false,
|
||||||
@@ -185,10 +137,9 @@ export const initialExploreState: ExploreState = {
|
|||||||
* Actions that have an `exploreId` get routed to the ExploreItemReducer.
|
* Actions that have an `exploreId` get routed to the ExploreItemReducer.
|
||||||
*/
|
*/
|
||||||
export const exploreReducer = (state = initialExploreState, action: AnyAction): ExploreState => {
|
export const exploreReducer = (state = initialExploreState, action: AnyAction): ExploreState => {
|
||||||
if (splitCloseAction.match(action)) {
|
if (splitClose.match(action)) {
|
||||||
const { itemId } = action.payload;
|
|
||||||
const panes = {
|
const panes = {
|
||||||
left: itemId === ExploreId.left ? state.panes.right : state.panes.left,
|
left: action.payload === ExploreId.left ? state.panes.right : state.panes.left,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -254,23 +205,6 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resetExploreAction.match(action)) {
|
|
||||||
// FIXME: reducers should REALLY not have side effects.
|
|
||||||
for (const [, pane] of Object.entries(state.panes).filter(([exploreId]) => exploreId !== ExploreId.left)) {
|
|
||||||
stopQueryState(pane!.querySubscription);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...initialExploreState,
|
|
||||||
panes: {
|
|
||||||
left: {
|
|
||||||
...initialExploreItemState,
|
|
||||||
queries: state.panes.left!.queries,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (richHistorySettingsUpdatedAction.match(action)) {
|
if (richHistorySettingsUpdatedAction.match(action)) {
|
||||||
const richHistorySettings = action.payload;
|
const richHistorySettings = action.payload;
|
||||||
return {
|
return {
|
||||||
@@ -299,21 +233,26 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.payload) {
|
if (clearPanes.match(action)) {
|
||||||
const { exploreId } = action.payload;
|
return {
|
||||||
if (exploreId !== undefined) {
|
...state,
|
||||||
return {
|
panes: {},
|
||||||
...state,
|
};
|
||||||
panes: Object.entries(state.panes).reduce<ExploreState['panes']>((acc, [id, pane]) => {
|
}
|
||||||
if (id === exploreId) {
|
|
||||||
acc[id as ExploreId] = paneReducer(pane, action);
|
const exploreId: ExploreId | undefined = action.payload?.exploreId;
|
||||||
} else {
|
if (typeof exploreId === 'string') {
|
||||||
acc[id as ExploreId] = pane;
|
return {
|
||||||
}
|
...state,
|
||||||
return acc;
|
panes: Object.entries(state.panes).reduce<ExploreState['panes']>((acc, [id, pane]) => {
|
||||||
}, {}),
|
if (id === exploreId) {
|
||||||
};
|
acc[id] = paneReducer(pane, action);
|
||||||
}
|
} else {
|
||||||
|
acc[id as ExploreId] = pane;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
|
@@ -158,7 +158,7 @@ describe('runQueries', () => {
|
|||||||
const { dispatch, getState } = setupTests();
|
const { dispatch, getState } = setupTests();
|
||||||
setupQueryResponse(getState());
|
setupQueryResponse(getState());
|
||||||
await dispatch(saveCorrelationsAction([]));
|
await dispatch(saveCorrelationsAction([]));
|
||||||
await dispatch(runQueries(ExploreId.left));
|
await dispatch(runQueries({ exploreId: ExploreId.left }));
|
||||||
expect(getState().explore.panes.left!.showMetrics).toBeTruthy();
|
expect(getState().explore.panes.left!.showMetrics).toBeTruthy();
|
||||||
expect(getState().explore.panes.left!.graphResult).toBeDefined();
|
expect(getState().explore.panes.left!.graphResult).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -167,7 +167,7 @@ describe('runQueries', () => {
|
|||||||
const { dispatch, getState } = setupTests();
|
const { dispatch, getState } = setupTests();
|
||||||
setupQueryResponse(getState());
|
setupQueryResponse(getState());
|
||||||
dispatch(saveCorrelationsAction([]));
|
dispatch(saveCorrelationsAction([]));
|
||||||
dispatch(runQueries(ExploreId.left));
|
dispatch(runQueries({ exploreId: ExploreId.left }));
|
||||||
|
|
||||||
const state = getState().explore.panes.left!;
|
const state = getState().explore.panes.left!;
|
||||||
expect(state.queryResponse.request?.requestId).toBe('explore_left');
|
expect(state.queryResponse.request?.requestId).toBe('explore_left');
|
||||||
@@ -187,7 +187,7 @@ describe('runQueries', () => {
|
|||||||
const leftDatasourceInstance = assertIsDefined(getState().explore.panes.left!.datasourceInstance);
|
const leftDatasourceInstance = assertIsDefined(getState().explore.panes.left!.datasourceInstance);
|
||||||
jest.mocked(leftDatasourceInstance.query).mockReturnValueOnce(EMPTY);
|
jest.mocked(leftDatasourceInstance.query).mockReturnValueOnce(EMPTY);
|
||||||
await dispatch(saveCorrelationsAction([]));
|
await dispatch(saveCorrelationsAction([]));
|
||||||
await dispatch(runQueries(ExploreId.left));
|
await dispatch(runQueries({ exploreId: ExploreId.left }));
|
||||||
await new Promise((resolve) => setTimeout(() => resolve(''), 500));
|
await new Promise((resolve) => setTimeout(() => resolve(''), 500));
|
||||||
expect(getState().explore.panes.left!.queryResponse.state).toBe(LoadingState.Done);
|
expect(getState().explore.panes.left!.queryResponse.state).toBe(LoadingState.Done);
|
||||||
});
|
});
|
||||||
@@ -195,7 +195,7 @@ describe('runQueries', () => {
|
|||||||
it('shows results only after correlations are loaded', async () => {
|
it('shows results only after correlations are loaded', async () => {
|
||||||
const { dispatch, getState } = setupTests();
|
const { dispatch, getState } = setupTests();
|
||||||
setupQueryResponse(getState());
|
setupQueryResponse(getState());
|
||||||
await dispatch(runQueries(ExploreId.left));
|
await dispatch(runQueries({ exploreId: ExploreId.left }));
|
||||||
expect(getState().explore.panes.left!.graphResult).not.toBeDefined();
|
expect(getState().explore.panes.left!.graphResult).not.toBeDefined();
|
||||||
await dispatch(saveCorrelationsAction([]));
|
await dispatch(saveCorrelationsAction([]));
|
||||||
expect(getState().explore.panes.left!.graphResult).toBeDefined();
|
expect(getState().explore.panes.left!.graphResult).toBeDefined();
|
||||||
@@ -791,7 +791,7 @@ describe('reducer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should cancel any unfinished supplementary queries when a new query is run', async () => {
|
it('should cancel any unfinished supplementary queries when a new query is run', async () => {
|
||||||
dispatch(runQueries(ExploreId.left));
|
dispatch(runQueries({ exploreId: ExploreId.left }));
|
||||||
// first query is run automatically
|
// first query is run automatically
|
||||||
// loading in progress - subscriptions for both supplementary queries are created, not cleaned up yet
|
// loading in progress - subscriptions for both supplementary queries are created, not cleaned up yet
|
||||||
expect(unsubscribes).toHaveLength(2);
|
expect(unsubscribes).toHaveLength(2);
|
||||||
@@ -799,7 +799,7 @@ describe('reducer', () => {
|
|||||||
expect(unsubscribes[1]).not.toBeCalled();
|
expect(unsubscribes[1]).not.toBeCalled();
|
||||||
|
|
||||||
setupQueryResponse(getState());
|
setupQueryResponse(getState());
|
||||||
dispatch(runQueries(ExploreId.left));
|
dispatch(runQueries({ exploreId: ExploreId.left }));
|
||||||
// a new query is run while supplementary queries are not resolve yet...
|
// a new query is run while supplementary queries are not resolve yet...
|
||||||
expect(unsubscribes[0]).toBeCalled();
|
expect(unsubscribes[0]).toBeCalled();
|
||||||
expect(unsubscribes[1]).toBeCalled();
|
expect(unsubscribes[1]).toBeCalled();
|
||||||
@@ -810,7 +810,7 @@ describe('reducer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should cancel all supported supplementary queries when the main query is canceled', () => {
|
it('should cancel all supported supplementary queries when the main query is canceled', () => {
|
||||||
dispatch(runQueries(ExploreId.left));
|
dispatch(runQueries({ exploreId: ExploreId.left }));
|
||||||
expect(unsubscribes).toHaveLength(2);
|
expect(unsubscribes).toHaveLength(2);
|
||||||
expect(unsubscribes[0]).not.toBeCalled();
|
expect(unsubscribes[0]).not.toBeCalled();
|
||||||
expect(unsubscribes[1]).not.toBeCalled();
|
expect(unsubscribes[1]).not.toBeCalled();
|
||||||
@@ -827,7 +827,7 @@ describe('reducer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should load supplementary queries after running the query', () => {
|
it('should load supplementary queries after running the query', () => {
|
||||||
dispatch(runQueries(ExploreId.left));
|
dispatch(runQueries({ exploreId: ExploreId.left }));
|
||||||
expect(unsubscribes).toHaveLength(2);
|
expect(unsubscribes).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -835,7 +835,7 @@ describe('reducer', () => {
|
|||||||
mockDataProvider = () => {
|
mockDataProvider = () => {
|
||||||
return of({ state: LoadingState.Loading, error: undefined, data: [] });
|
return of({ state: LoadingState.Loading, error: undefined, data: [] });
|
||||||
};
|
};
|
||||||
dispatch(runQueries(ExploreId.left));
|
dispatch(runQueries({ exploreId: ExploreId.left }));
|
||||||
|
|
||||||
for (const type of supplementaryQueryTypes) {
|
for (const type of supplementaryQueryTypes) {
|
||||||
expect(getState().explore.panes.left!.supplementaryQueries[type].data).toBeDefined();
|
expect(getState().explore.panes.left!.supplementaryQueries[type].data).toBeDefined();
|
||||||
@@ -862,7 +862,7 @@ describe('reducer', () => {
|
|||||||
{ state: LoadingState.Done, error: undefined, data: [{}] }
|
{ state: LoadingState.Done, error: undefined, data: [{}] }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
dispatch(runQueries(ExploreId.left));
|
dispatch(runQueries({ exploreId: ExploreId.left }));
|
||||||
|
|
||||||
for (const types of supplementaryQueryTypes) {
|
for (const types of supplementaryQueryTypes) {
|
||||||
expect(getState().explore.panes.left!.supplementaryQueries[types].data).toBeDefined();
|
expect(getState().explore.panes.left!.supplementaryQueries[types].data).toBeDefined();
|
||||||
@@ -891,7 +891,7 @@ describe('reducer', () => {
|
|||||||
expect(getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsSample].enabled).toBe(true);
|
expect(getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsSample].enabled).toBe(true);
|
||||||
|
|
||||||
// verify that if we run a query, it will: 1) not do logs volume, 2) do logs sample 3) provider will still be set for both
|
// verify that if we run a query, it will: 1) not do logs volume, 2) do logs sample 3) provider will still be set for both
|
||||||
dispatch(runQueries(ExploreId.left));
|
dispatch(runQueries({ exploreId: ExploreId.left }));
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].data
|
getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].data
|
||||||
@@ -918,7 +918,7 @@ describe('reducer', () => {
|
|||||||
dispatch(setSupplementaryQueryEnabled(ExploreId.left, false, SupplementaryQueryType.LogsSample));
|
dispatch(setSupplementaryQueryEnabled(ExploreId.left, false, SupplementaryQueryType.LogsSample));
|
||||||
|
|
||||||
// runQueries sets up providers, but does not run queries
|
// runQueries sets up providers, but does not run queries
|
||||||
dispatch(runQueries(ExploreId.left));
|
dispatch(runQueries({ exploreId: ExploreId.left }));
|
||||||
expect(
|
expect(
|
||||||
getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].dataProvider
|
getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].dataProvider
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
|
@@ -59,14 +59,9 @@ import {
|
|||||||
} from '../utils/supplementaryQueries';
|
} from '../utils/supplementaryQueries';
|
||||||
|
|
||||||
import { addHistoryItem, historyUpdatedAction, loadRichHistory } from './history';
|
import { addHistoryItem, historyUpdatedAction, loadRichHistory } from './history';
|
||||||
import { stateSave } from './main';
|
|
||||||
import { updateTime } from './time';
|
import { updateTime } from './time';
|
||||||
import { createCacheKey, getResultsFromCache, filterLogRowsByIndex } from './utils';
|
import { createCacheKey, getResultsFromCache, filterLogRowsByIndex } from './utils';
|
||||||
|
|
||||||
//
|
|
||||||
// Actions and Payloads
|
|
||||||
//
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a query row after the row with the given index.
|
* Adds a query row after the row with the given index.
|
||||||
*/
|
*/
|
||||||
@@ -232,10 +227,6 @@ export interface ClearCachePayload {
|
|||||||
}
|
}
|
||||||
export const clearCacheAction = createAction<ClearCachePayload>('explore/clearCache');
|
export const clearCacheAction = createAction<ClearCachePayload>('explore/clearCache');
|
||||||
|
|
||||||
//
|
|
||||||
// Action creators
|
|
||||||
//
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a query row after the row with the given index.
|
* Adds a query row after the row with the given index.
|
||||||
*/
|
*/
|
||||||
@@ -277,7 +268,6 @@ export function cancelQueries(exploreId: ExploreId): ThunkResult<void> {
|
|||||||
dispatch(cleanSupplementaryQueryAction({ exploreId, type }));
|
dispatch(cleanSupplementaryQueryAction({ exploreId, type }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dispatch(stateSave());
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,8 +320,8 @@ export const changeQueries = createAsyncThunk<void, ChangeQueriesPayload>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// if we are removing a query we want to run the remaining ones
|
// if we are removing a query we want to run the remaining ones
|
||||||
if (queries.length < queries.length) {
|
if (queries.length < oldQueries.length) {
|
||||||
dispatch(runQueries(exploreId));
|
dispatch(runQueries({ exploreId }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -435,7 +425,7 @@ export function modifyQueries(
|
|||||||
|
|
||||||
dispatch(setQueriesAction({ exploreId, queries: nextQueries }));
|
dispatch(setQueriesAction({ exploreId, queries: nextQueries }));
|
||||||
if (!modification.preventSubmit) {
|
if (!modification.preventSubmit) {
|
||||||
dispatch(runQueries(exploreId));
|
dispatch(runQueries({ exploreId }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -462,21 +452,22 @@ async function handleHistory(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RunQueriesOptions {
|
||||||
|
exploreId: ExploreId;
|
||||||
|
preserveCache?: boolean;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Main action to run queries and dispatches sub-actions based on which result viewers are active
|
* Main action to run queries and dispatches sub-actions based on which result viewers are active
|
||||||
*/
|
*/
|
||||||
export const runQueries = (
|
export const runQueries = createAsyncThunk<void, RunQueriesOptions>(
|
||||||
exploreId: ExploreId,
|
'explore/runQueries',
|
||||||
options?: { replaceUrl?: boolean; preserveCache?: boolean }
|
async ({ exploreId, preserveCache }, { dispatch, getState }) => {
|
||||||
): ThunkResult<void> => {
|
|
||||||
return (dispatch, getState) => {
|
|
||||||
dispatch(updateTime({ exploreId }));
|
dispatch(updateTime({ exploreId }));
|
||||||
|
|
||||||
const correlations$ = getCorrelations();
|
const correlations$ = getCorrelations();
|
||||||
|
|
||||||
// We always want to clear cache unless we explicitly pass preserveCache parameter
|
// We always want to clear cache unless we explicitly pass preserveCache parameter
|
||||||
const preserveCache = options?.preserveCache === true;
|
if (preserveCache !== true) {
|
||||||
if (!preserveCache) {
|
|
||||||
dispatch(clearCache(exploreId));
|
dispatch(clearCache(exploreId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,8 +497,6 @@ export const runQueries = (
|
|||||||
handleHistory(dispatch, getState().explore, exploreItemState.history, datasourceInstance, queries, exploreId);
|
handleHistory(dispatch, getState().explore, exploreItemState.history, datasourceInstance, queries, exploreId);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(stateSave({ replace: options?.replaceUrl }));
|
|
||||||
|
|
||||||
const cachedValue = getResultsFromCache(cache, absoluteRange);
|
const cachedValue = getResultsFromCache(cache, absoluteRange);
|
||||||
|
|
||||||
// If we have results saved in cache, we are going to use those results instead of running queries
|
// If we have results saved in cache, we are going to use those results instead of running queries
|
||||||
@@ -519,21 +508,12 @@ export const runQueries = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
newQuerySubscription = newQuerySource.subscribe((data) => {
|
newQuerySubscription = newQuerySource.subscribe((data) => {
|
||||||
if (!data.error) {
|
|
||||||
dispatch(stateSave());
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(queryStreamUpdatedAction({ exploreId, response: data }));
|
dispatch(queryStreamUpdatedAction({ exploreId, response: data }));
|
||||||
});
|
});
|
||||||
|
|
||||||
// If we don't have results saved in cache, run new queries
|
// If we don't have results saved in cache, run new queries
|
||||||
} else {
|
} else {
|
||||||
if (!hasNonEmptyQuery(queries)) {
|
if (!hasNonEmptyQuery(queries) || !datasourceInstance) {
|
||||||
dispatch(stateSave({ replace: options?.replaceUrl })); // Remember to save to state and update location
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!datasourceInstance) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,7 +567,7 @@ export const runQueries = (
|
|||||||
if (data.state === LoadingState.Done && data.series.length === 0) {
|
if (data.state === LoadingState.Done && data.series.length === 0) {
|
||||||
const range = getShiftedTimeRange(-1, getState().explore.panes[exploreId]!.range);
|
const range = getShiftedTimeRange(-1, getState().explore.panes[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 }));
|
||||||
@@ -635,8 +615,8 @@ export const runQueries = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispatch(queryStoreSubscriptionAction({ exploreId, querySubscription: newQuerySubscription }));
|
dispatch(queryStoreSubscriptionAction({ exploreId, querySubscription: newQuerySubscription }));
|
||||||
};
|
}
|
||||||
};
|
);
|
||||||
|
|
||||||
const groupDataQueries = async (datasources: DataQuery[], scopedVars: ScopedVars) => {
|
const groupDataQueries = async (datasources: DataQuery[], scopedVars: ScopedVars) => {
|
||||||
const nonMixedDataSources = datasources.filter((t) => {
|
const nonMixedDataSources = datasources.filter((t) => {
|
||||||
@@ -781,7 +761,7 @@ export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): Thunk
|
|||||||
const queries = getState().explore.panes[exploreId]!.queries;
|
const queries = getState().explore.panes[exploreId]!.queries;
|
||||||
const nextQueries = rawQueries.map((query, index) => generateNewKeyAndAddRefIdIfMissing(query, queries, index));
|
const nextQueries = rawQueries.map((query, index) => generateNewKeyAndAddRefIdIfMissing(query, queries, index));
|
||||||
dispatch(setQueriesAction({ exploreId, queries: nextQueries }));
|
dispatch(setQueriesAction({ exploreId, queries: nextQueries }));
|
||||||
dispatch(runQueries(exploreId));
|
dispatch(runQueries({ exploreId }));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,7 +778,7 @@ export function scanStart(exploreId: ExploreId): ThunkResult<void> {
|
|||||||
const range = getShiftedTimeRange(-1, getState().explore.panes[exploreId]!.range);
|
const range = getShiftedTimeRange(-1, getState().explore.panes[exploreId]!.range);
|
||||||
// Set the new range to be displayed
|
// Set the new range to be displayed
|
||||||
dispatch(updateTime({ exploreId, absoluteRange: range }));
|
dispatch(updateTime({ exploreId, absoluteRange: range }));
|
||||||
dispatch(runQueries(exploreId));
|
dispatch(runQueries({ exploreId }));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
19
public/app/features/explore/state/selectors.test.ts
Normal file
19
public/app/features/explore/state/selectors.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { initialExploreState } from './main';
|
||||||
|
import { selectOrderedExplorePanes } from './selectors';
|
||||||
|
import { makeExplorePaneState } from './utils';
|
||||||
|
|
||||||
|
describe('getOrderedExplorePanes', () => {
|
||||||
|
it('returns a panes object with entries in the correct order', () => {
|
||||||
|
const selectorResult = selectOrderedExplorePanes({
|
||||||
|
explore: {
|
||||||
|
...initialExploreState,
|
||||||
|
panes: {
|
||||||
|
right: makeExplorePaneState(),
|
||||||
|
left: makeExplorePaneState(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Object.keys(selectorResult)).toEqual(['left', 'right']);
|
||||||
|
});
|
||||||
|
});
|
@@ -1,5 +1,26 @@
|
|||||||
import { ExploreId, StoreState } from 'app/types';
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
export const isSplit = (state: StoreState) => Object.keys(state.explore.panes).length > 1;
|
import { ExploreId, ExploreState, StoreState } from 'app/types';
|
||||||
|
|
||||||
export const getExploreItemSelector = (exploreId: ExploreId) => (state: StoreState) => state.explore.panes[exploreId];
|
export const selectPanes = (state: Pick<StoreState, 'explore'>) => state.explore.panes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explore renders panes by iterating over the panes object. This selector ensures that entries in the returned panes object
|
||||||
|
* are in the correct order.
|
||||||
|
*/
|
||||||
|
export const selectOrderedExplorePanes = createSelector(selectPanes, (panes) => {
|
||||||
|
const orderedPanes: ExploreState['panes'] = {};
|
||||||
|
|
||||||
|
if (panes.left) {
|
||||||
|
orderedPanes.left = panes.left;
|
||||||
|
}
|
||||||
|
if (panes.right) {
|
||||||
|
orderedPanes.right = panes.right;
|
||||||
|
}
|
||||||
|
return orderedPanes;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isSplit = createSelector(selectPanes, (panes) => Object.keys(panes).length > 1);
|
||||||
|
|
||||||
|
export const getExploreItemSelector = (exploreId: ExploreId) =>
|
||||||
|
createSelector(selectPanes, (panes) => panes[exploreId]);
|
||||||
|
@@ -4,10 +4,8 @@ import { dateTime, LoadingState } from '@grafana/data';
|
|||||||
import { configureStore } from 'app/store/configureStore';
|
import { configureStore } from 'app/store/configureStore';
|
||||||
import { ExploreId, ExploreItemState } from 'app/types';
|
import { ExploreId, ExploreItemState } from 'app/types';
|
||||||
|
|
||||||
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput';
|
|
||||||
|
|
||||||
import { createDefaultInitialState } from './helpers';
|
import { createDefaultInitialState } from './helpers';
|
||||||
import { changeRangeAction, changeRefreshIntervalAction, timeReducer, updateTime } from './time';
|
import { changeRangeAction, changeRefreshInterval, timeReducer, updateTime } from './time';
|
||||||
import { makeExplorePaneState } from './utils';
|
import { makeExplorePaneState } from './utils';
|
||||||
|
|
||||||
const MOCK_TIME_RANGE = {};
|
const MOCK_TIME_RANGE = {};
|
||||||
@@ -30,14 +28,10 @@ jest.mock('@grafana/runtime', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('Explore item reducer', () => {
|
describe('Explore item reducer', () => {
|
||||||
silenceConsoleOutput();
|
|
||||||
|
|
||||||
describe('When time is updated', () => {
|
describe('When time is updated', () => {
|
||||||
it('Time service is re-initialized and template service is updated with the new time range', async () => {
|
it('Time service is re-initialized and template service is updated with the new time range', async () => {
|
||||||
const { dispatch } = configureStore({
|
const { dispatch } = configureStore(createDefaultInitialState().defaultInitialState as any);
|
||||||
...(createDefaultInitialState() as any),
|
dispatch(updateTime({ exploreId: ExploreId.left }));
|
||||||
});
|
|
||||||
await dispatch(updateTime({ exploreId: ExploreId.left }));
|
|
||||||
expect(mockTimeSrv.init).toBeCalled();
|
expect(mockTimeSrv.init).toBeCalled();
|
||||||
expect(mockTemplateSrv.updateTimeRange).toBeCalledWith(MOCK_TIME_RANGE);
|
expect(mockTemplateSrv.updateTimeRange).toBeCalledWith(MOCK_TIME_RANGE);
|
||||||
});
|
});
|
||||||
@@ -62,7 +56,7 @@ describe('Explore item reducer', () => {
|
|||||||
};
|
};
|
||||||
reducerTester<ExploreItemState>()
|
reducerTester<ExploreItemState>()
|
||||||
.givenReducer(timeReducer, initialState)
|
.givenReducer(timeReducer, initialState)
|
||||||
.whenActionIsDispatched(changeRefreshIntervalAction({ exploreId: ExploreId.left, refreshInterval: 'LIVE' }))
|
.whenActionIsDispatched(changeRefreshInterval({ exploreId: ExploreId.left, refreshInterval: 'LIVE' }))
|
||||||
.thenStateShouldEqual(expectedState);
|
.thenStateShouldEqual(expectedState);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,7 +76,7 @@ describe('Explore item reducer', () => {
|
|||||||
};
|
};
|
||||||
reducerTester<ExploreItemState>()
|
reducerTester<ExploreItemState>()
|
||||||
.givenReducer(timeReducer, initialState)
|
.givenReducer(timeReducer, initialState)
|
||||||
.whenActionIsDispatched(changeRefreshIntervalAction({ exploreId: ExploreId.left, refreshInterval: '' }))
|
.whenActionIsDispatched(changeRefreshInterval({ exploreId: ExploreId.left, refreshInterval: '' }))
|
||||||
.thenStateShouldEqual(expectedState);
|
.thenStateShouldEqual(expectedState);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { AnyAction, createAction, PayloadAction } from '@reduxjs/toolkit';
|
import { AnyAction, createAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import { AbsoluteTimeRange, dateTimeForTimeZone, LoadingState, RawTimeRange, TimeRange } from '@grafana/data';
|
import { AbsoluteTimeRange, dateTimeForTimeZone, LoadingState, RawTimeRange, TimeRange } from '@grafana/data';
|
||||||
import { getTemplateSrv } from '@grafana/runtime';
|
import { getTemplateSrv } from '@grafana/runtime';
|
||||||
@@ -12,7 +12,7 @@ import { ExploreId } from 'app/types/explore';
|
|||||||
import { getTimeSrv } from '../../dashboard/services/TimeSrv';
|
import { getTimeSrv } from '../../dashboard/services/TimeSrv';
|
||||||
import { TimeModel } from '../../dashboard/state/TimeModel';
|
import { TimeModel } from '../../dashboard/state/TimeModel';
|
||||||
|
|
||||||
import { syncTimesAction, stateSave } from './main';
|
import { syncTimesAction } from './main';
|
||||||
import { runQueries } from './query';
|
import { runQueries } from './query';
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -33,7 +33,7 @@ export interface ChangeRefreshIntervalPayload {
|
|||||||
exploreId: ExploreId;
|
exploreId: ExploreId;
|
||||||
refreshInterval: string;
|
refreshInterval: string;
|
||||||
}
|
}
|
||||||
export const changeRefreshIntervalAction = createAction<ChangeRefreshIntervalPayload>('explore/changeRefreshInterval');
|
export const changeRefreshInterval = createAction<ChangeRefreshIntervalPayload>('explore/changeRefreshInterval');
|
||||||
|
|
||||||
export const updateTimeRange = (options: {
|
export const updateTimeRange = (options: {
|
||||||
exploreId: ExploreId;
|
exploreId: ExploreId;
|
||||||
@@ -45,25 +45,15 @@ export const updateTimeRange = (options: {
|
|||||||
if (syncedTimes) {
|
if (syncedTimes) {
|
||||||
Object.keys(getState().explore.panes).forEach((exploreId) => {
|
Object.keys(getState().explore.panes).forEach((exploreId) => {
|
||||||
dispatch(updateTime({ ...options, exploreId: exploreId as ExploreId }));
|
dispatch(updateTime({ ...options, exploreId: exploreId as ExploreId }));
|
||||||
dispatch(runQueries(exploreId as ExploreId, { preserveCache: true }));
|
dispatch(runQueries({ exploreId: exploreId as ExploreId, preserveCache: true }));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
dispatch(updateTime({ ...options }));
|
dispatch(updateTime({ ...options }));
|
||||||
dispatch(runQueries(options.exploreId, { preserveCache: true }));
|
dispatch(runQueries({ exploreId: options.exploreId, preserveCache: true }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Change the refresh interval of Explore. Called from the Refresh picker.
|
|
||||||
*/
|
|
||||||
export function changeRefreshInterval(
|
|
||||||
exploreId: ExploreId,
|
|
||||||
refreshInterval: string
|
|
||||||
): PayloadAction<ChangeRefreshIntervalPayload> {
|
|
||||||
return changeRefreshIntervalAction({ exploreId, refreshInterval });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateTime = (config: {
|
export const updateTime = (config: {
|
||||||
exploreId: ExploreId;
|
exploreId: ExploreId;
|
||||||
rawRange?: RawTimeRange;
|
rawRange?: RawTimeRange;
|
||||||
@@ -126,7 +116,6 @@ export function syncTimes(exploreId: ExploreId): ThunkResult<void> {
|
|||||||
|
|
||||||
const isTimeSynced = getState().explore.syncedTimes;
|
const isTimeSynced = getState().explore.syncedTimes;
|
||||||
dispatch(syncTimesAction({ syncedTimes: !isTimeSynced }));
|
dispatch(syncTimesAction({ syncedTimes: !isTimeSynced }));
|
||||||
dispatch(stateSave());
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,8 +134,6 @@ export function makeAbsoluteTime(): ThunkResult<void> {
|
|||||||
const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
|
const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
|
||||||
dispatch(updateTime({ exploreId: exploreId as ExploreId, absoluteRange }));
|
dispatch(updateTime({ exploreId: exploreId as ExploreId, absoluteRange }));
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch(stateSave());
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +146,7 @@ export function makeAbsoluteTime(): ThunkResult<void> {
|
|||||||
// the frozen state.
|
// the frozen state.
|
||||||
// https://github.com/reduxjs/redux-toolkit/issues/242
|
// https://github.com/reduxjs/redux-toolkit/issues/242
|
||||||
export const timeReducer = (state: ExploreItemState, action: AnyAction): ExploreItemState => {
|
export const timeReducer = (state: ExploreItemState, action: AnyAction): ExploreItemState => {
|
||||||
if (changeRefreshIntervalAction.match(action)) {
|
if (changeRefreshInterval.match(action)) {
|
||||||
const { refreshInterval } = action.payload;
|
const { refreshInterval } = action.payload;
|
||||||
const live = RefreshPicker.isLive(refreshInterval);
|
const live = RefreshPicker.isLive(refreshInterval);
|
||||||
const sortOrder = refreshIntervalToSortOrder(refreshInterval);
|
const sortOrder = refreshIntervalToSortOrder(refreshInterval);
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import store from '../../../core/store';
|
import { dateTime } from '@grafana/data';
|
||||||
import { lastUsedDatasourceKeyForOrgId } from '../../../core/utils/explore';
|
import * as exploreUtils from 'app/core/utils/explore';
|
||||||
|
|
||||||
const dataSourceMock = {
|
const dataSourceMock = {
|
||||||
get: jest.fn(),
|
get: jest.fn(),
|
||||||
@@ -8,19 +8,24 @@ jest.mock('app/features/plugins/datasource_srv', () => ({
|
|||||||
getDatasourceSrv: jest.fn(() => dataSourceMock),
|
getDatasourceSrv: jest.fn(() => dataSourceMock),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.spyOn(store, 'set');
|
import { loadAndInitDatasource, getRange } from './utils';
|
||||||
|
|
||||||
import { loadAndInitDatasource } from './utils';
|
|
||||||
|
|
||||||
const DEFAULT_DATASOURCE = { uid: 'abc123', name: 'Default' };
|
const DEFAULT_DATASOURCE = { uid: 'abc123', name: 'Default' };
|
||||||
const TEST_DATASOURCE = { uid: 'def789', name: 'Test' };
|
const TEST_DATASOURCE = { uid: 'def789', name: 'Test' };
|
||||||
|
|
||||||
describe('loadAndInitDatasource', () => {
|
describe('loadAndInitDatasource', () => {
|
||||||
beforeEach(() => {
|
let setLastUsedDatasourceUIDSpy;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to default datasource if the provided one was not found', async () => {
|
it('falls back to default datasource if the provided one was not found', async () => {
|
||||||
|
setLastUsedDatasourceUIDSpy = jest.spyOn(exploreUtils, 'setLastUsedDatasourceUID');
|
||||||
dataSourceMock.get.mockRejectedValueOnce(new Error('Datasource not found'));
|
dataSourceMock.get.mockRejectedValueOnce(new Error('Datasource not found'));
|
||||||
dataSourceMock.get.mockResolvedValue(DEFAULT_DATASOURCE);
|
dataSourceMock.get.mockResolvedValue(DEFAULT_DATASOURCE);
|
||||||
|
|
||||||
@@ -30,10 +35,11 @@ describe('loadAndInitDatasource', () => {
|
|||||||
expect(dataSourceMock.get).toBeCalledWith({ uid: 'Unknown' });
|
expect(dataSourceMock.get).toBeCalledWith({ uid: 'Unknown' });
|
||||||
expect(dataSourceMock.get).toBeCalledWith();
|
expect(dataSourceMock.get).toBeCalledWith();
|
||||||
expect(instance).toMatchObject(DEFAULT_DATASOURCE);
|
expect(instance).toMatchObject(DEFAULT_DATASOURCE);
|
||||||
expect(store.set).toBeCalledWith(lastUsedDatasourceKeyForOrgId(1), DEFAULT_DATASOURCE.uid);
|
expect(setLastUsedDatasourceUIDSpy).toBeCalledWith(1, DEFAULT_DATASOURCE.uid);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('saves last loaded data source uid', async () => {
|
it('saves last loaded data source uid', async () => {
|
||||||
|
setLastUsedDatasourceUIDSpy = jest.spyOn(exploreUtils, 'setLastUsedDatasourceUID');
|
||||||
dataSourceMock.get.mockResolvedValue(TEST_DATASOURCE);
|
dataSourceMock.get.mockResolvedValue(TEST_DATASOURCE);
|
||||||
|
|
||||||
const { instance } = await loadAndInitDatasource(1, { uid: 'Test' });
|
const { instance } = await loadAndInitDatasource(1, { uid: 'Test' });
|
||||||
@@ -41,6 +47,39 @@ describe('loadAndInitDatasource', () => {
|
|||||||
expect(dataSourceMock.get).toBeCalledTimes(1);
|
expect(dataSourceMock.get).toBeCalledTimes(1);
|
||||||
expect(dataSourceMock.get).toBeCalledWith({ uid: 'Test' });
|
expect(dataSourceMock.get).toBeCalledWith({ uid: 'Test' });
|
||||||
expect(instance).toMatchObject(TEST_DATASOURCE);
|
expect(instance).toMatchObject(TEST_DATASOURCE);
|
||||||
expect(store.set).toBeCalledWith(lastUsedDatasourceKeyForOrgId(1), TEST_DATASOURCE.uid);
|
expect(setLastUsedDatasourceUIDSpy).toBeCalledWith(1, TEST_DATASOURCE.uid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRange', () => {
|
||||||
|
it('should parse moment date', () => {
|
||||||
|
// convert date strings to moment object
|
||||||
|
const range = { from: dateTime('2020-10-22T10:44:33.615Z'), to: dateTime('2020-10-22T10:49:33.615Z') };
|
||||||
|
const result = getRange(range, 'browser');
|
||||||
|
expect(result.raw).toEqual(range);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse epoch strings', () => {
|
||||||
|
const range = {
|
||||||
|
from: dateTime('2020-10-22T10:00:00Z').valueOf().toString(),
|
||||||
|
to: dateTime('2020-10-22T11:00:00Z').valueOf().toString(),
|
||||||
|
};
|
||||||
|
const result = getRange(range, 'browser');
|
||||||
|
expect(result.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf());
|
||||||
|
expect(result.to.valueOf()).toEqual(dateTime('2020-10-22T11:00:00Z').valueOf());
|
||||||
|
expect(result.raw.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf());
|
||||||
|
expect(result.raw.to.valueOf()).toEqual(dateTime('2020-10-22T11:00:00Z').valueOf());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse ISO strings', () => {
|
||||||
|
const range = {
|
||||||
|
from: dateTime('2020-10-22T10:00:00Z').toISOString(),
|
||||||
|
to: dateTime('2020-10-22T11:00:00Z').toISOString(),
|
||||||
|
};
|
||||||
|
const result = getRange(range, 'browser');
|
||||||
|
expect(result.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf());
|
||||||
|
expect(result.to.valueOf()).toEqual(dateTime('2020-10-22T11:00:00Z').valueOf());
|
||||||
|
expect(result.raw.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf());
|
||||||
|
expect(result.raw.to.valueOf()).toEqual(dateTime('2020-10-22T11:00:00Z').valueOf());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,25 +1,28 @@
|
|||||||
import { isEmpty, isObject, mapValues, omitBy } from 'lodash';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AbsoluteTimeRange,
|
AbsoluteTimeRange,
|
||||||
DataSourceApi,
|
DataSourceApi,
|
||||||
EventBusExtended,
|
EventBusExtended,
|
||||||
ExploreUrlState,
|
|
||||||
getDefaultTimeRange,
|
getDefaultTimeRange,
|
||||||
HistoryItem,
|
HistoryItem,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
LogRowModel,
|
LogRowModel,
|
||||||
PanelData,
|
PanelData,
|
||||||
|
RawTimeRange,
|
||||||
|
TimeFragment,
|
||||||
|
TimeRange,
|
||||||
|
dateMath,
|
||||||
|
DateTime,
|
||||||
|
isDateTime,
|
||||||
|
toUtc,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { DataSourceRef } from '@grafana/schema';
|
import { DataSourceRef, TimeZone } from '@grafana/schema';
|
||||||
import { ExplorePanelData } from 'app/types';
|
import { ExplorePanelData } from 'app/types';
|
||||||
import { ExploreItemState } from 'app/types/explore';
|
import { ExploreItemState } from 'app/types/explore';
|
||||||
|
|
||||||
import store from '../../../core/store';
|
import store from '../../../core/store';
|
||||||
import { clearQueryKeys, lastUsedDatasourceKeyForOrgId } from '../../../core/utils/explore';
|
import { setLastUsedDatasourceUID } from '../../../core/utils/explore';
|
||||||
import { getDatasourceSrv } from '../../plugins/datasource_srv';
|
import { getDatasourceSrv } from '../../plugins/datasource_srv';
|
||||||
import { loadSupplementaryQueries } from '../utils/supplementaryQueries';
|
import { loadSupplementaryQueries } from '../utils/supplementaryQueries';
|
||||||
import { toRawTimeRange } from '../utils/time';
|
|
||||||
|
|
||||||
export const DEFAULT_RANGE = {
|
export const DEFAULT_RANGE = {
|
||||||
from: 'now-6h',
|
from: 'now-6h',
|
||||||
@@ -37,7 +40,6 @@ export const storeGraphStyle = (graphStyle: string): void => {
|
|||||||
export const makeExplorePaneState = (): ExploreItemState => ({
|
export const makeExplorePaneState = (): ExploreItemState => ({
|
||||||
containerWidth: 0,
|
containerWidth: 0,
|
||||||
datasourceInstance: null,
|
datasourceInstance: null,
|
||||||
datasourceMissing: false,
|
|
||||||
history: [],
|
history: [],
|
||||||
queries: [],
|
queries: [],
|
||||||
initialized: false,
|
initialized: false,
|
||||||
@@ -112,33 +114,10 @@ export async function loadAndInitDatasource(
|
|||||||
const history = store.getObject<HistoryItem[]>(historyKey, []);
|
const history = store.getObject<HistoryItem[]>(historyKey, []);
|
||||||
// Save last-used datasource
|
// Save last-used datasource
|
||||||
|
|
||||||
store.set(lastUsedDatasourceKeyForOrgId(orgId), instance.uid);
|
setLastUsedDatasourceUID(orgId, instance.uid);
|
||||||
return { history, instance };
|
return { history, instance };
|
||||||
}
|
}
|
||||||
|
|
||||||
// recursively walks an object, removing keys where the value is undefined
|
|
||||||
// if the resulting object is empty, returns undefined
|
|
||||||
function pruneObject(obj: object): object | undefined {
|
|
||||||
let pruned = mapValues(obj, (value) => (isObject(value) ? pruneObject(value) : value));
|
|
||||||
pruned = omitBy<typeof pruned>(pruned, isEmpty);
|
|
||||||
if (isEmpty(pruned)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return pruned;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUrlStateFromPaneState(pane: ExploreItemState): ExploreUrlState {
|
|
||||||
return {
|
|
||||||
// datasourceInstance should not be undefined anymore here but in case there is some path for it to be undefined
|
|
||||||
// lets just fallback instead of crashing.
|
|
||||||
datasource: pane.datasourceInstance?.uid || '',
|
|
||||||
queries: pane.queries.map(clearQueryKeys),
|
|
||||||
range: toRawTimeRange(pane.range),
|
|
||||||
// don't include panelsState in the url unless a piece of state is actually set
|
|
||||||
panelsState: pruneObject(pane.panelsState),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCacheKey(absRange: AbsoluteTimeRange) {
|
export function createCacheKey(absRange: AbsoluteTimeRange) {
|
||||||
const params = {
|
const params = {
|
||||||
from: absRange.from,
|
from: absRange.from,
|
||||||
@@ -161,6 +140,57 @@ export function getResultsFromCache(
|
|||||||
return cacheValue;
|
return cacheValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRange(range: RawTimeRange, timeZone: TimeZone): TimeRange {
|
||||||
|
const raw = {
|
||||||
|
from: parseRawTime(range.from)!,
|
||||||
|
to: parseRawTime(range.to)!,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: dateMath.parse(raw.from, false, timeZone)!,
|
||||||
|
to: dateMath.parse(raw.to, true, timeZone)!,
|
||||||
|
raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRawTime(value: string | DateTime): TimeFragment | null {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDateTime(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.indexOf('now') !== -1) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value.length === 8) {
|
||||||
|
return toUtc(value, 'YYYYMMDD');
|
||||||
|
}
|
||||||
|
if (value.length === 15) {
|
||||||
|
return toUtc(value, 'YYYYMMDDTHHmmss');
|
||||||
|
}
|
||||||
|
// Backward compatibility
|
||||||
|
if (value.length === 19) {
|
||||||
|
return toUtc(value, 'YYYY-MM-DD HH:mm:ss');
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should handle cases where value is an epoch time as string
|
||||||
|
if (value.match(/^\d+$/)) {
|
||||||
|
const epoch = parseInt(value, 10);
|
||||||
|
return toUtc(epoch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should handle ISO strings
|
||||||
|
const time = toUtc(value);
|
||||||
|
if (time.isValid()) {
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export const filterLogRowsByIndex = (
|
export const filterLogRowsByIndex = (
|
||||||
clearedAtIndex: ExploreItemState['clearedAtIndex'],
|
clearedAtIndex: ExploreItemState['clearedAtIndex'],
|
||||||
logRows?: LogRowModel[]
|
logRows?: LogRowModel[]
|
||||||
|
@@ -30,12 +30,12 @@ export function useLiveTailControls(exploreId: ExploreId) {
|
|||||||
|
|
||||||
// TODO referencing this from perspective of refresh picker when there is designated button for it now is not
|
// TODO referencing this from perspective of refresh picker when there is designated button for it now is not
|
||||||
// great. Needs a bit of refactoring.
|
// great. Needs a bit of refactoring.
|
||||||
dispatch(changeRefreshInterval(exploreId, RefreshPicker.offOption.value));
|
dispatch(changeRefreshInterval({ exploreId, refreshInterval: RefreshPicker.offOption.value }));
|
||||||
dispatch(runQueries(exploreId));
|
dispatch(runQueries({ exploreId }));
|
||||||
}, [exploreId, dispatch, pause]);
|
}, [exploreId, dispatch, pause]);
|
||||||
|
|
||||||
const start = useCallback(() => {
|
const start = useCallback(() => {
|
||||||
dispatch(changeRefreshInterval(exploreId, RefreshPicker.liveOption.value));
|
dispatch(changeRefreshInterval({ exploreId, refreshInterval: RefreshPicker.liveOption.value }));
|
||||||
}, [exploreId, dispatch]);
|
}, [exploreId, dispatch]);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
|
32
public/app/features/explore/utils/queries.ts
Normal file
32
public/app/features/explore/utils/queries.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { DataQuery } from '@grafana/schema';
|
||||||
|
import { getNextRefIdChar } from 'app/core/utils/query';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes sure all the queries have unique (and valid) refIds
|
||||||
|
*/
|
||||||
|
export function withUniqueRefIds(queries: DataQuery[]): DataQuery[] {
|
||||||
|
const refIds = new Set<string>(queries.map((query) => query.refId).filter(Boolean));
|
||||||
|
|
||||||
|
if (refIds.size === queries.length) {
|
||||||
|
return queries;
|
||||||
|
}
|
||||||
|
|
||||||
|
refIds.clear();
|
||||||
|
|
||||||
|
return queries.map((query) => {
|
||||||
|
if (query.refId && !refIds.has(query.refId)) {
|
||||||
|
refIds.add(query.refId);
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refId = getNextRefIdChar(queries);
|
||||||
|
refIds.add(refId);
|
||||||
|
|
||||||
|
const newQuery = {
|
||||||
|
...query,
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return newQuery;
|
||||||
|
});
|
||||||
|
}
|
@@ -1,18 +0,0 @@
|
|||||||
import { isDateTime, RawTimeRange, TimeRange } from '@grafana/data';
|
|
||||||
|
|
||||||
export const toRawTimeRange = (range: TimeRange): RawTimeRange => {
|
|
||||||
let from = range.raw.from;
|
|
||||||
if (isDateTime(from)) {
|
|
||||||
from = from.valueOf().toString(10);
|
|
||||||
}
|
|
||||||
|
|
||||||
let to = range.raw.to;
|
|
||||||
if (isDateTime(to)) {
|
|
||||||
to = to.valueOf().toString(10);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
};
|
|
||||||
};
|
|
@@ -1,4 +1,4 @@
|
|||||||
import { configureStore as reduxConfigureStore } from '@reduxjs/toolkit';
|
import { configureStore as reduxConfigureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||||
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
|
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
|
||||||
@@ -17,11 +17,14 @@ export function addRootReducer(reducers: any) {
|
|||||||
addReducer(reducers);
|
addReducer(reducers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listenerMiddleware = createListenerMiddleware();
|
||||||
|
|
||||||
export function configureStore(initialState?: Partial<StoreState>) {
|
export function configureStore(initialState?: Partial<StoreState>) {
|
||||||
const store = reduxConfigureStore({
|
const store = reduxConfigureStore({
|
||||||
reducer: createRootReducer(),
|
reducer: createRootReducer(),
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }).concat(
|
getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }).concat(
|
||||||
|
listenerMiddleware.middleware,
|
||||||
alertingApi.middleware,
|
alertingApi.middleware,
|
||||||
publicDashboardApi.middleware,
|
publicDashboardApi.middleware,
|
||||||
browseDashboardsAPI.middleware
|
browseDashboardsAPI.middleware
|
||||||
|
@@ -26,8 +26,8 @@ export enum ExploreId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ExploreQueryParams = {
|
export type ExploreQueryParams = {
|
||||||
left: string;
|
left?: string;
|
||||||
right: string;
|
right?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,10 +93,6 @@ export interface ExploreItemState {
|
|||||||
* Datasource instance that has been selected. Datasource-specific logic can be run on this object.
|
* Datasource instance that has been selected. Datasource-specific logic can be run on this object.
|
||||||
*/
|
*/
|
||||||
datasourceInstance?: DataSourceApi | null;
|
datasourceInstance?: DataSourceApi | null;
|
||||||
/**
|
|
||||||
* True if there is no datasource to be selected.
|
|
||||||
*/
|
|
||||||
datasourceMissing: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Emitter to send events to the rest of Grafana.
|
* Emitter to send events to the rest of Grafana.
|
||||||
*/
|
*/
|
||||||
@@ -211,8 +207,6 @@ export interface ExploreItemState {
|
|||||||
supplementaryQueries: SupplementaryQueries;
|
supplementaryQueries: SupplementaryQueries;
|
||||||
|
|
||||||
panelsState: ExplorePanelsState;
|
panelsState: ExplorePanelsState;
|
||||||
|
|
||||||
isFromCompactUrl?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExploreUpdateState {
|
export interface ExploreUpdateState {
|
||||||
|
@@ -1,11 +1,13 @@
|
|||||||
/* eslint-disable no-restricted-imports */
|
/* eslint-disable no-restricted-imports */
|
||||||
import {
|
import {
|
||||||
Action,
|
Action,
|
||||||
|
addListener as addListenerUntyped,
|
||||||
AsyncThunk,
|
AsyncThunk,
|
||||||
AsyncThunkOptions,
|
AsyncThunkOptions,
|
||||||
AsyncThunkPayloadCreator,
|
AsyncThunkPayloadCreator,
|
||||||
createAsyncThunk as createAsyncThunkUntyped,
|
createAsyncThunk as createAsyncThunkUntyped,
|
||||||
PayloadAction,
|
PayloadAction,
|
||||||
|
TypedAddListener,
|
||||||
} from '@reduxjs/toolkit';
|
} from '@reduxjs/toolkit';
|
||||||
import {
|
import {
|
||||||
useSelector as useSelectorUntyped,
|
useSelector as useSelectorUntyped,
|
||||||
@@ -37,3 +39,5 @@ export const createAsyncThunk = <Returned, ThunkArg = void, ThunkApiConfig exten
|
|||||||
options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
|
options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
|
||||||
): AsyncThunk<Returned, ThunkArg, ThunkApiConfig> =>
|
): AsyncThunk<Returned, ThunkArg, ThunkApiConfig> =>
|
||||||
createAsyncThunkUntyped<Returned, ThunkArg, ThunkApiConfig>(typePrefix, payloadCreator, options);
|
createAsyncThunkUntyped<Returned, ThunkArg, ThunkApiConfig>(typePrefix, payloadCreator, options);
|
||||||
|
|
||||||
|
export const addListener = addListenerUntyped as TypedAddListener<RootState, AppDispatch>;
|
||||||
|
Reference in New Issue
Block a user