Explore: Refactor & centralize URL/state sync (#66286)

Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>
This commit is contained in:
Giordano Ricci 2023-06-06 15:31:39 +01:00 committed by GitHub
parent 6900336f09
commit 067bbcbe56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1360 additions and 1441 deletions

View File

@ -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.", "2"],
[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.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
[0, 0, 0, "Unexpected any. Specify a different type.", "7"]
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
],
"public/app/core/utils/fetch.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
@ -2560,6 +2557,9 @@ exports[`better eslint`] = {
"public/app/features/explore/ElapsedTime.tsx:5381": [
[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": [
[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.", "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": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[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"]
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/features/explore/spec/interpolation.test.tsx:5381": [
[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": [
[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": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"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.", "1"]
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/explore/state/query.ts:5381": [
[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"]
],
"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": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]

View File

@ -16,6 +16,7 @@ e2e.scenario({
cy.get('button[title="Delete query"]').each((button) => {
button.trigger('click');
});
cy.get('button[title="Delete query"]').should('not.exist');
e2e.components.QueryTab.queryHistoryButton().should('be.visible').click();
e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer()
@ -36,7 +37,7 @@ e2e.scenario({
cy.get('body').click();
cy.get('body').type('t{leftarrow}');
cy.location().then((locPostKeypress) => {
cy.location().should((locPostKeypress) => {
const params = new URLSearchParams(locPostKeypress.search);
const leftJSON = JSON.parse(params.get('left'));
// be sure the keypress affected the time window

View File

@ -11,7 +11,6 @@ export interface ExploreUrlState<T extends DataQuery = AnyQuery> {
range: RawTimeRange;
context?: string;
panelsState?: ExplorePanelsState;
isFromCompactUrl?: boolean;
}
export interface ExplorePanelsState extends Partial<Record<PreferredVisualisationType, {}>> {

View File

@ -8,17 +8,13 @@ import { DatasourceSrvMock, MockDataSourceApi } from '../../../test/mocks/dataso
import {
buildQueryTransaction,
clearHistory,
DEFAULT_RANGE,
getRefIds,
getValueWithRefId,
hasNonEmptyQuery,
parseUrlState,
refreshIntervalToSortOrder,
updateHistory,
getExploreUrl,
GetExploreUrlArguments,
getTimeRangeFromUrl,
getTimeRange,
generateEmptyQuery,
} from './explore';
@ -140,7 +136,6 @@ describe('state functions', () => {
const state = {
...DEFAULT_EXPLORE_STATE,
datasource: 'foo',
isFromCompactUrl: false,
queries: [
{
expr: 'metric{test="a/b"}',
@ -165,7 +160,6 @@ describe('state functions', () => {
const state = {
...DEFAULT_EXPLORE_STATE,
datasource: 'foo',
isFromCompactUrl: false,
queries: [
{
expr: 'metric{test="a/b"}',
@ -228,7 +222,7 @@ describe('updateHistory()', () => {
const key = `grafana.explore.history.${datasourceId}`;
beforeEach(() => {
clearHistory(datasourceId);
store.delete(key);
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('should flip from and to when from is after to', () => {
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('when called with live option', () => {
it('then it should return ascending', () => {

View File

@ -1,4 +1,4 @@
import { flatten, omit, uniq } from 'lodash';
import { omit } from 'lodash';
import { Unsubscribable } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
@ -8,21 +8,16 @@ import {
DataQueryRequest,
DataSourceApi,
DataSourceRef,
dateMath,
DateTime,
DefaultTimeZone,
ExploreUrlState,
HistoryItem,
IntervalValues,
isDateTime,
LogsDedupStrategy,
LogsSortOrder,
rangeUtil,
RawTimeRange,
TimeFragment,
TimeRange,
TimeZone,
toUtc,
urlUtil,
} from '@grafana/data';
import { DataSourceSrv, getDataSourceSrv } from '@grafana/runtime';
@ -48,8 +43,12 @@ export const DEFAULT_UI_STATE = {
const MAX_HISTORY_ITEMS = 100;
export const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
export const lastUsedDatasourceKeyForOrgId = (orgId: number) => `${LAST_USED_DATASOURCE_KEY}.${orgId}`;
const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
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 {
panel: PanelModel;
@ -223,8 +222,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
}
if (!Array.isArray(parsed)) {
const urlState = { ...parsed, isFromCompactUrl: false };
return urlState;
return { queries: [], range: DEFAULT_RANGE, ...parsed };
}
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 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 {
@ -285,15 +283,6 @@ export const generateNewKeyAndAddRefIdIfMissing = (target: DataQuery, queries: D
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
*
@ -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[] => {
const queryKeys = queries.reduce<string[]>((newQueryKeys, query, index) => {
const primaryKey = query.datasource?.uid || query.key;
@ -420,105 +404,6 @@ export const getTimeRange = (timeZone: TimeZone, rawRange: RawTimeRange, fiscalY
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) =>
RefreshPicker.isLive(refreshInterval) ? LogsSortOrder.Ascending : LogsSortOrder.Descending;

View File

@ -5,11 +5,13 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { DataSourceApi, LoadingState, CoreApp, createTheme, EventBusSrv } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { configureStore } from 'app/store/configureStore';
import { ExploreId } from 'app/types';
import { Explore, Props } from './Explore';
import { initialExploreState } from './state/main';
import { scanStopAction } from './state/query';
import { createEmptyQueryResponse } from './state/utils';
import { createEmptyQueryResponse, makeExplorePaneState } from './state/utils';
const resizeWindow = (x: number, y: number) => {
global.innerWidth = x;
@ -59,7 +61,6 @@ const dummyProps: Props = {
QueryEditorHelp: {},
},
} as DataSourceApi,
datasourceMissing: false,
exploreId: ExploreId.left,
loading: false,
modifyQueries: jest.fn(),
@ -89,7 +90,6 @@ const dummyProps: Props = {
showFlameGraph: true,
splitOpen: jest.fn(),
splitted: false,
isFromCompactUrl: false,
eventBus: new EventBusSrv(),
showRawPrometheus: false,
showLogsSample: false,
@ -119,10 +119,18 @@ jest.mock('react-virtualized-auto-sizer', () => {
});
const setup = (overrideProps?: Partial<Props>) => {
const store = configureStore({
explore: {
...initialExploreState,
panes: {
left: makeExplorePaneState(),
},
},
});
const exploreProps = { ...dummyProps, ...overrideProps };
return render(
<TestProvider>
<TestProvider store={store}>
<Explore {...exploreProps} />
</TestProvider>
);
@ -139,8 +147,7 @@ describe('Explore', () => {
});
it('should render no data with done loading state', async () => {
const queryResp = makeEmptyQueryResponse(LoadingState.Done);
setup({ queryResponse: queryResp });
setup({ queryResponse: makeEmptyQueryResponse(LoadingState.Done) });
// Wait for the Explore component to render
await screen.findByTestId(selectors.components.DataSourcePicker.container);

View File

@ -25,18 +25,16 @@ import {
Themeable2,
withTheme2,
PanelContainer,
Alert,
AdHocFilterItem,
} from '@grafana/ui';
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/src/components/Table/types';
import appEvents from 'app/core/app_events';
import { FadeIn } from 'app/core/components/Animations/FadeIn';
import { supportedFeatures } from 'app/core/history/richHistoryStorageProvider';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { getNodeGraphDataFrames } from 'app/plugins/panel/nodeGraph/utils';
import { StoreState } from 'app/types';
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';
@ -285,17 +283,6 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
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) {
const { graphResult, absoluteRange, timeZone, queryResponse, loading, showFlameGraph } = this.props;
@ -426,7 +413,6 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
render() {
const {
datasourceInstance,
datasourceMissing,
exploreId,
graphResult,
queryResponse,
@ -440,7 +426,6 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
showNodeGraph,
showFlameGraph,
timeZone,
isFromCompactUrl,
showLogsSample,
} = this.props;
const { openDrawer } = this.state;
@ -468,9 +453,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
scrollRefCallback={(scrollElement) => (this.scrollElement = scrollElement || undefined)}
>
<ExploreToolbar exploreId={exploreId} onChangeTime={this.onChangeTime} topOfViewRef={this.topOfViewRef} />
{isFromCompactUrl ? this.renderCompactUrlWarning() : null}
{datasourceMissing ? this.renderEmptyState(styles.exploreContainer) : null}
{datasourceInstance && (
{datasourceInstance ? (
<div className={styles.exploreContainer}>
<PanelContainer className={styles.queryContainer}>
<QueryRows exploreId={exploreId} />
@ -537,6 +520,8 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
}}
</AutoSizer>
</div>
) : (
this.renderEmptyState(styles.exploreContainer)
)}
</CustomScrollbar>
);
@ -546,11 +531,11 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
const explore = state.explore;
const { syncedTimes } = explore;
const item: ExploreItemState = explore.panes[exploreId]!;
const item = explore.panes[exploreId]!;
const timeZone = getTimeZone(state.user);
const {
datasourceInstance,
datasourceMissing,
queryKeys,
queries,
isLive,
@ -566,7 +551,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
showNodeGraph,
showFlameGraph,
loading,
isFromCompactUrl,
showRawPrometheus,
supplementaryQueries,
} = item;
@ -577,7 +561,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
return {
datasourceInstance,
datasourceMissing,
queryKeys,
queries,
isLive,
@ -596,7 +579,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
showFlameGraph,
splitted: isSplit(state),
loading,
isFromCompactUrl: isFromCompactUrl || false,
logsSample,
showLogsSample,
};

View File

@ -32,7 +32,7 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => {
name: 'Run query (left)',
keywords: 'query left',
perform: () => {
dispatch(runQueries(exploreIdLeft));
dispatch(runQueries({ exploreId: exploreIdLeft }));
},
section: exploreSection,
});
@ -43,7 +43,7 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => {
name: 'Run query (right)',
keywords: 'query right',
perform: () => {
dispatch(runQueries(exploreIdRight));
dispatch(runQueries({ exploreId: exploreIdRight }));
},
section: exploreSection,
});
@ -72,7 +72,7 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => {
name: 'Run query',
keywords: 'query',
perform: () => {
dispatch(runQueries(exploreIdLeft));
dispatch(runQueries({ exploreId: exploreIdLeft }));
},
section: exploreSection,
});

View File

@ -4,12 +4,12 @@ import React, { ComponentProps } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
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 { setupExplore, tearDown, waitForExplore } from './spec/helper/setup';
import * as mainState from './state/main';
import * as queryState from './state/query';
jest.mock('app/core/core', () => {
return {
@ -40,13 +40,49 @@ describe('ExplorePage', () => {
describe('Handles open/close splits and related events in UI and URL', () => {
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
const splitButton = await screen.findByText(/split/i);
const splitButton = await screen.findByRole('button', { name: /split/i });
await userEvent.click(splitButton);
await waitFor(() => {
const editors = screen.getAllByText('loki Editor input:');
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.elastic.query).mockReturnValueOnce(makeLogsQueryResponse());
@ -79,14 +115,11 @@ describe('ExplorePage', () => {
expect(logsLines.length).toBe(2);
// And that the editor gets the expr from the url
await screen.findByText(`loki Editor input: { label="value"}`);
await screen.findByText(`elastic Editor input: error`);
expect(screen.getByText(`loki Editor input: { label="value"}`)).toBeInTheDocument();
expect(screen.getByText(`elastic Editor input: error`)).toBeInTheDocument();
// We did not change the url
expect(locationService.getSearchObject()).toEqual({
orgId: '1',
...urlParams,
});
expect(location.getSearchObject()).toEqual(expect.objectContaining(urlParams));
// We called the data source query method once
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 () => {
const urlParams = {
left: JSON.stringify(['now-1h', 'now', 'loki', { refId: 'A' }]),
right: JSON.stringify(['now-1h', 'now', 'elastic', { refId: 'A' }]),
};
setupExplore({ urlParams });
const closeButtons = await screen.findAllByLabelText(/Close split pane/i);
const { location } = setupExplore({ urlParams });
let closeButtons = await screen.findAllByLabelText(/Close split pane/i);
await userEvent.click(closeButtons[1]);
expect(location.getHistory().length).toBe(1);
await waitFor(() => {
const postCloseButtons = screen.queryAllByLabelText(/Close split pane/i);
expect(postCloseButtons.length).toBe(0);
closeButtons = screen.queryAllByLabelText(/Close split pane/i);
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 = {
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.elastic.query).mockReturnValue(makeLogsQueryResponse());
await waitFor(() => {
expect(screen.getByText(`loki Editor input: { label="value"}`)).toBeInTheDocument();
});
act(() => {
locationService.partial({
location.partial({
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
right: JSON.stringify(['now-1h', 'now', 'elastic', { expr: 'error' }]),
});
});
// Editor renders the new query
await screen.findByText(`loki Editor input: { label="value"}`);
await screen.findByText(`elastic Editor input: error`);
await waitFor(() => {
expect(screen.getByText(`loki Editor input: { label="value"}`)).toBeInTheDocument();
expect(screen.getByText(`elastic Editor input: error`)).toBeInTheDocument();
});
});
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.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
// to work
await screen.findByText(`loki Editor input: { label="value"}`);
// Wait for the left pane to render
await waitFor(async () => {
expect(await screen.findByText(`loki Editor input: { label="value"}`)).toBeInTheDocument();
});
act(() => {
store.dispatch(mainState.splitOpen({ datasourceUid: 'elastic', query: { expr: 'error', refId: 'A' } }));
});
// Editor renders the new query
await screen.findByText(`elastic Editor input: error`);
await screen.findByText(`loki Editor input: { label="value"}`);
expect(await screen.findByText(`elastic Editor input: error`)).toBeInTheDocument();
expect(await screen.findByText(`loki Editor input: { label="value"}`)).toBeInTheDocument();
});
it('handles split size events and sets relevant variables', async () => {
setupExplore();
const splitButton = await screen.findByText(/split/i);
await userEvent.click(splitButton);
await waitForExplore(undefined, true);
let widenButton = await screen.findAllByLabelText('Widen pane');
let narrowButton = await screen.queryAllByLabelText('Narrow pane');
await waitForExplore(ExploreId.left);
expect(await screen.findAllByLabelText('Widen pane')).toHaveLength(2);
expect(screen.queryByLabelText('Narrow pane')).not.toBeInTheDocument();
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[1]).width, 10)).toBe(1000);
const resizer = screen.getByRole('presentation');
fireEvent.mouseDown(resizer, { buttons: 1 });
fireEvent.mouseMove(resizer, { clientX: -700, buttons: 1 });
fireEvent.mouseUp(resizer);
widenButton = await screen.findAllByLabelText('Widen pane');
narrowButton = await screen.queryAllByLabelText('Narrow pane');
expect(widenButton.length).toBe(1);
expect(narrowButton.length).toBe(1);
// the autosizer is mocked so there is no actual resize here
expect(await screen.findAllByLabelText('Widen pane')).toHaveLength(1);
expect(await screen.findAllByLabelText('Narrow pane')).toHaveLength(1);
});
});
@ -214,205 +260,241 @@ describe('ExplorePage', () => {
});
describe('Handles different URL datasource redirects', () => {
it('No params, no store value uses default data source', async () => {
setupExplore();
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('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' },
describe('exploreMixedDatasource on', () => {
beforeAll(() => {
config.featureToggles.exploreMixedDatasource = true;
});
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 () => {
setupExplore({
urlParams:
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}',
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
describe('When root datasource is not specified in the URL', () => {
it('Redirects to default datasource', async () => {
const { location } = setupExplore({ 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);
});
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 () => {
setupExplore({
urlParams: 'orgId=1&left={"datasource":"loki","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}',
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
describe('When root datasource is specified in the URL', () => {
it('Uses the datasource in the URL', async () => {
const { location } = setupExplore({
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 () => {
setupExplore({
urlParams:
'orgId=1&left={"queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}',
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
it('Queries using nonexisting datasources gets removed', async () => {
const { location } = setupExplore({
urlParams: {
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' },
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 () => {
setupExplore({
urlParams:
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}',
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
it('Only keeps queries using 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());
// 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 () => {
setupExplore({
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"}}'
);
describe('exploreMixedDatasource off', () => {
beforeAll(() => {
config.featureToggles.exploreMixedDatasource = false;
});
it('Different datasources in query with mixed feature off uses first query DS, converts rest', async () => {
config.featureToggles.exploreMixedDatasource = false;
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' },
it('Redirects to the first query datasource if the root is mixed', async () => {
const { location } = setupExplore({
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"}}',
},
mixedEnabled: false,
});
const reducerMock = jest.spyOn(queryState, 'queryReducer');
await waitForExplore(undefined, true);
const urlParams = decodeURIComponent(locationService.getSearch().toString());
// because there are no import/export queries in our mock datasources, only the first one remains
expect(reducerMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
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"}}'
);
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('Datasource in root not found and no queries changes to default', async () => {
setupExplore({
urlParams: 'orgId=1&left={"datasource":"asdasdasd","range":{"from":"now-1h","to":"now"}}',
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
it('Redirects to the default datasource if the root is mixed and there are no queries', async () => {
const { location } = setupExplore({
urlParams: {
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 () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
setupExplore({
urlParams:
'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' },
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":"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 () => {
setupExplore({ searchParams: 'from=1&to=2&orgId=1' });
const { location } = setupExplore({ urlParams: { from: '1', to: '2' } });
await waitForExplore();
expect(locationService.getSearchObject()).toEqual(expect.not.objectContaining({ from: '1', to: '2' }));
expect(locationService.getSearchObject()).toEqual(expect.objectContaining({ orgId: '1' }));
await waitFor(() => {
expect(location.getSearchObject()).toEqual(expect.not.objectContaining({ from: '1', to: '2' }));
expect(location.getSearchObject()).toEqual(expect.objectContaining({ orgId: '1' }));
});
});
});

View File

@ -1,25 +1,25 @@
import { css } from '@emotion/css';
import { inRange } from 'lodash';
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useWindowSize } from 'react-use';
import { isTruthy } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { ErrorBoundaryAlert, usePanelContext } from '@grafana/ui';
import { ErrorBoundaryAlert } from '@grafana/ui';
import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { useAppNotification } from 'app/core/copy/appNotification';
import { useNavModel } from 'app/core/hooks/useNavModel';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { useDispatch, useSelector } from 'app/types';
import { ExploreId, ExploreQueryParams } from 'app/types/explore';
import { Branding } from '../../core/components/Branding/Branding';
import { useCorrelations } from '../correlations/useCorrelations';
import { ExploreActions } from './ExploreActions';
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 = {
pageScrollbarWrapper: css`
@ -32,20 +32,21 @@ const styles = {
};
export default function ExplorePage(props: GrafanaRouteComponentProps<{}, ExploreQueryParams>) {
useStopQueries();
useTimeSrvFix();
useStateSync(props.queryParams);
useExplorePageTitle();
useExploreCorrelations();
const dispatch = useDispatch();
const queryParams = props.queryParams;
const { keybindings, chrome, config } = useGrafana();
const { keybindings, chrome } = useGrafana();
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 { width: windowWidth } = useWindowSize();
const minWidth = 200;
const exploreState = useSelector((state) => state.explore);
const panes = useSelector(selectOrderedExplorePanes);
useEffect(() => {
//This is needed for breadcrumbs and topnav.
//We should probably abstract this out at some point
@ -56,52 +57,6 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
keybindings.setupTimeRangeBindings(false);
}, [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 evenSplitWidth = windowWidth / 2;
const areBothSimilar = inRange(size, evenSplitWidth - 100, evenSplitWidth + 100);
@ -118,7 +73,7 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
setRightPaneWidthRatio(size / windowWidth);
};
const hasSplit = Boolean(queryParams.left) && Boolean(queryParams.right);
const hasSplit = Object.entries(panes).length > 1;
let widthCalc = 0;
if (hasSplit) {
if (!exploreState.evenSplitPanes && exploreState.maxedExploreId) {
@ -148,30 +103,14 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
}
}}
>
<ErrorBoundaryAlert style="page">
<ExplorePaneContainer exploreId={ExploreId.left} urlQuery={queryParams.left} eventBus={eventBus.current} />
</ErrorBoundaryAlert>
{hasSplit && (
<ErrorBoundaryAlert style="page">
<ExplorePaneContainer
exploreId={ExploreId.right}
urlQuery={queryParams.right}
eventBus={eventBus.current}
/>
</ErrorBoundaryAlert>
)}
{Object.keys(panes).map((exploreId) => {
return (
<ErrorBoundaryAlert key={exploreId} style="page">
<ExplorePaneContainer exploreId={exploreId as ExploreId} />
</ErrorBoundaryAlert>
);
})}
</SplitPaneWrapper>
</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}`;
};

View File

@ -1,35 +1,14 @@
import { css } from '@emotion/css';
import memoizeOne from 'memoize-one';
import React from 'react';
import { connect, ConnectedProps } from 'react-redux';
import React, { useEffect, useRef } from 'react';
import { connect } from 'react-redux';
import { EventBusExtended, EventBusSrv, GrafanaTheme2, EventBus } from '@grafana/data';
import { EventBusSrv, GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
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 { useStyles2 } from '@grafana/ui';
import { StoreState } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { getDatasourceSrv } from '../plugins/datasource_srv';
import { getFiscalYearStartMonth, getTimeZone } from '../profile/state/selectors';
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) => {
return {
@ -46,158 +25,42 @@ const getStyles = (theme: GrafanaTheme2) => {
};
};
interface OwnProps extends Themeable2 {
interface Props {
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.
/**
* 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.
*/
class ExplorePaneContainerUnconnected extends React.PureComponent<Props> {
el: HTMLDivElement | null = null;
exploreEvents: EventBusExtended;
You can read more about this issue here: https://react-redux.js.org/api/hooks#stale-props-and-zombie-children
*/
function ExplorePaneContainerUnconnected({ exploreId }: Props) {
const styles = useStyles2(getStyles);
const eventBus = useRef(new EventBusSrv());
const ref = useRef(null);
constructor(props: Props) {
super(props);
this.exploreEvents = new EventBusSrv();
this.state = {
openDrawer: undefined,
};
}
useEffect(() => {
const bus = eventBus.current;
return () => bus.removeAllListeners();
}, []);
async componentDidMount() {
const {
initialized,
exploreId,
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>
);
}
return (
<div className={styles.explore} ref={ref} data-testid={selectors.pages.Explore.General.container}>
<Explore exploreId={exploreId} eventBus={eventBus.current} />
</div>
);
}
const getTimeRangeFromUrlMemoized = memoizeOne(getTimeRangeFromUrl);
function mapStateToProps(state: StoreState, props: Props) {
const pane = state.explore.panes[props.exploreId];
function mapStateToProps(state: StoreState, props: OwnProps) {
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,
};
return { pane };
}
const mapDispatchToProps = {
initializeExplore,
refreshExplore,
importQueries,
stateSave,
};
const connector = connect(mapStateToProps);
const connector = connect(mapStateToProps, mapDispatchToProps);
export const ExplorePaneContainer = withTheme2(connector(ExplorePaneContainerUnconnected));
export const ExplorePaneContainer = connector(ExplorePaneContainerUnconnected);

View File

@ -68,7 +68,9 @@ export function ExploreQueryInspector(props: Props) {
label: 'Query',
value: 'query',
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];

View File

@ -88,7 +88,7 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props)
if (loading) {
return dispatch(cancelQueries(exploreId));
} else {
return dispatch(runQueries(exploreId));
return dispatch(runQueries({ exploreId }));
}
};
@ -119,8 +119,8 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props)
const onChangeFiscalYearStartMonth = (fiscalyearStartMonth: number) =>
dispatch(updateFiscalYearStartMonthForSession(fiscalyearStartMonth));
const onChangeRefreshInterval = (item: string) => {
dispatch(changeRefreshInterval(exploreId, item));
const onChangeRefreshInterval = (refreshInterval: string) => {
dispatch(changeRefreshInterval({ exploreId, refreshInterval }));
};
const showExploreToDashboard = useMemo(

View File

@ -11,7 +11,7 @@ import { ExploreId } from 'app/types/explore';
import { getDatasourceSrv } from '../plugins/datasource_srv';
import { QueryEditorRows } from '../query/components/QueryEditorRows';
import { runQueries, changeQueries } from './state/query';
import { changeQueries, runQueries } from './state/query';
import { getExploreItemSelector } from './state/selectors';
interface Props {
@ -39,14 +39,14 @@ export const QueryRows = ({ exploreId }: Props) => {
[exploreId]
);
const queries = useSelector(getQueries)!;
const dsSettings = useSelector(getDatasourceInstanceSettings)!;
const queryResponse = useSelector(getQueryResponse)!;
const queries = useSelector(getQueries);
const dsSettings = useSelector(getDatasourceInstanceSettings);
const queryResponse = useSelector(getQueryResponse);
const history = useSelector(getHistory);
const eventBridge = useSelector(getEventBridge);
const onRunQueries = useCallback(() => {
dispatch(runQueries(exploreId));
dispatch(runQueries({ exploreId }));
}, [dispatch, exploreId]);
const onChange = useCallback(

View File

@ -1,14 +1,15 @@
import { render, screen } from '@testing-library/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 { ExploreId } from 'app/types';
import { configureStore } from '../../store/configureStore';
import { ResponseErrorContainer } from './ResponseErrorContainer';
import { createEmptyQueryResponse, makeExplorePaneState } from './state/utils';
describe('ResponseErrorContainer', () => {
it('shows error message if it does not contain refId', async () => {
@ -46,26 +47,20 @@ describe('ResponseErrorContainer', () => {
function setup(error: DataQueryError) {
const store = configureStore();
store.getState().explore.panes.left!.queryResponse = {
timeRange: getDefaultTimeRange(),
series: [],
state: LoadingState.Error,
error,
graphFrames: [],
logsFrames: [],
tableFrames: [],
traceFrames: [],
nodeGraphFrames: [],
rawPrometheusFrames: [],
flameGraphFrames: [],
graphResult: null,
logsResult: null,
tableResult: null,
rawPrometheusResult: null,
store.getState().explore.panes = {
left: {
...makeExplorePaneState(),
queryResponse: {
...createEmptyQueryResponse(),
state: LoadingState.Error,
error,
},
},
};
render(
<Provider store={store}>
<TestProvider store={store}>
<ResponseErrorContainer exploreId={ExploreId.left} />
</Provider>
</TestProvider>
);
}

View 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]);
}

View 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}`;
}

View 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,
};
};

View 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);
}
};
}, []);
}

View 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]);
}

View File

@ -1,7 +1,6 @@
import { screen, waitFor } from '@testing-library/react';
import { serializeStateToUrlParam } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { changeDatasource } from './helper/interactions';
import { makeLogsQueryResponse } from './helper/query';
@ -18,20 +17,23 @@ describe('Explore: handle datasource states', () => {
it('handles changing the datasource manually', async () => {
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());
await waitForExplore();
await changeDatasource('elastic');
await screen.findByText('elastic Editor input:');
expect(datasources.elastic.query).not.toBeCalled();
expect(locationService.getSearchObject()).toEqual({
orgId: '1',
left: serializeStateToUrlParam({
datasource: 'elastic-uid',
queries: [{ refId: 'A', datasource: { type: 'logs', uid: 'elastic-uid' } }],
range: { from: 'now-1h', to: 'now' },
}),
await waitFor(async () => {
expect(location.getSearchObject()).toEqual({
orgId: '1',
left: serializeStateToUrlParam({
datasource: 'elastic-uid',
queries: [{ refId: 'A', datasource: { type: 'logs', uid: 'elastic-uid' } }],
range: { from: 'now-1h', to: 'now' },
}),
});
});
});
});

View File

@ -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 { stringify } from 'querystring';
import React from 'react';
import { Provider } from 'react-redux';
import { Route, Router } from 'react-router-dom';
@ -8,35 +11,40 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import {
DataSourceApi,
DataSourceInstanceSettings,
DataSourceRef,
QueryEditorProps,
ScopedVars,
UrlQueryValue,
DataSourcePluginMeta,
PluginType,
} 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 { GrafanaRoute } from 'app/core/navigation/GrafanaRoute';
import { Echo } from 'app/core/services/echo/Echo';
import store from 'app/core/store';
import { lastUsedDatasourceKeyForOrgId } from 'app/core/utils/explore';
import { setLastUsedDatasourceUID } from 'app/core/utils/explore';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { configureStore } from 'app/store/configureStore';
import { LokiDatasource } from '../../../../plugins/datasource/loki/datasource';
import { LokiQuery } from '../../../../plugins/datasource/loki/types';
import { ExploreId } from '../../../../types';
import { ExploreId, ExploreQueryParams } from '../../../../types';
import { initialUserState } from '../../../profile/state/reducers';
import ExplorePage from '../../ExplorePage';
type DatasourceSetup = { settings: DataSourceInstanceSettings; api: DataSourceApi };
type SetupOptions = {
// default true
clearLocalStorage?: boolean;
datasources?: DatasourceSetup[];
urlParams?: { left: string; right?: string } | string;
searchParams?: string;
urlParams?: ExploreQueryParams & { [key: string]: string };
prevUsedDatasource?: { orgId: number; datasource: string };
mixedEnabled?: boolean;
};
export function setupExplore(options?: SetupOptions): {
@ -44,6 +52,7 @@ export function setupExplore(options?: SetupOptions): {
store: ReturnType<typeof configureStore>;
unmount: () => void;
container: HTMLElement;
location: LocationService;
} {
// Clear this up otherwise it persists data source selection
// TODO: probably add test for that too
@ -52,7 +61,7 @@ export function setupExplore(options?: SetupOptions): {
}
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
@ -62,7 +71,7 @@ export function setupExplore(options?: SetupOptions): {
];
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;
@ -71,24 +80,30 @@ export function setupExplore(options?: SetupOptions): {
getList(): DataSourceInstanceSettings[] {
return dsSettings.map((d) => d.settings);
},
getInstanceSettings(ref: DataSourceRef) {
return dsSettings.map((d) => d.settings).find((x) => x.name === ref || x.uid === ref || x.uid === ref.uid);
getInstanceSettings(ref?: DataSourceRef) {
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> {
if (dsSettings.length === 0) {
return Promise.resolve(undefined);
get(datasource?: string | DataSourceRef | null): Promise<DataSourceApi> {
let ds: DataSourceApi | undefined;
if (!datasource) {
ds = dsSettings[0]?.api;
} else {
const datasourceStr = typeof datasource === 'string';
return Promise.resolve(
(datasource
? dsSettings.find((d) =>
datasourceStr ? d.api.name === datasource || d.api.uid === datasource : d.api.uid === datasource?.uid
)
: dsSettings[0])!.api
);
ds = dsSettings.find((ds) =>
typeof datasource === 'string'
? ds.api.name === datasource || ds.api.uid === datasource
: ds.api.uid === datasource?.uid
)?.api;
}
if (ds) {
return Promise.resolve(ds);
}
return Promise.reject();
},
} as any);
reload() {},
});
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) {
let urlParams: Record<string, string | UrlQueryValue> =
typeof options.urlParams === 'string' ? locationSearchToObject(options.urlParams) : options.urlParams;
locationService.partial(urlParams);
}
const location = new HistoryWrapper(history);
setLocationService(location);
const route = { component: ExplorePage };
const contextMock = getGrafanaContextMock({ location });
const { unmount, container } = render(
<Provider store={storeState}>
<GrafanaContext.Provider value={getGrafanaContextMock()}>
<Router history={locationService.getHistory()}>
<Route path="/explore" exact render={(props) => <GrafanaRoute {...props} route={route as any} />} />
<GrafanaContext.Provider
value={{
...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>
</GrafanaContext.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 {
const meta: any = {
function makeDatasourceSetup({
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: {
author: {
name: 'Grafana',
},
description: '',
links: [],
screenshots: [],
updated: '',
version: '',
logos: {
small: '',
large: '',
},
},
id: id.toString(),
module: 'loki',
name,
type: PluginType.datasource,
baseUrl: '',
};
return {
settings: {
id,
uid: `${name}-uid`,
type: 'logs',
uid,
type,
name,
meta,
access: 'proxy',
@ -170,20 +224,19 @@ function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: nu
},
},
name: name,
uid: `${name}-uid`,
uid: uid,
query: jest.fn(),
getRef: jest.fn().mockReturnValue({ type: 'logs', uid: `${name}-uid` }),
getRef: () => ({ type, uid }),
meta,
} as any,
};
}
export const waitForExplore = async (exploreId: ExploreId = ExploreId.left, multi = false) => {
if (multi) {
return await withinExplore(exploreId).findAllByText(/Editor/i);
} else {
return await withinExplore(exploreId).findByText(/Editor/i);
}
export const waitForExplore = (exploreId: ExploreId = ExploreId.left) => {
return waitFor(async () => {
const container = screen.getAllByTestId('data-testid Explore');
return within(container[exploreId === ExploreId.left ? 0 : 1]);
});
};
export const tearDown = () => {

View File

@ -47,6 +47,7 @@ jest.mock('@grafana/runtime', () => ({
jest.mock('app/core/core', () => ({
contextSrv: {
hasPermission: () => true,
hasAccess: () => true,
isSignedIn: true,
},
@ -92,7 +93,7 @@ describe('Explore: Query History', () => {
it('adds new query history items after the query is run.', async () => {
// when Explore is opened
const { datasources, unmount } = setupExplore();
(datasources.loki.query as jest.Mock).mockReturnValueOnce(makeLogsQueryResponse());
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
await waitForExplore();
// and a user runs a query and opens query history
@ -127,7 +128,7 @@ describe('Explore: Query History', () => {
};
const { datasources } = setupExplore({ urlParams });
(datasources.loki.query as jest.Mock).mockReturnValueOnce(makeLogsQueryResponse());
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
await waitForExplore();
await openQueryHistory();
@ -136,7 +137,7 @@ describe('Explore: Query History', () => {
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 = {
left: serializeStateToUrlParam({
datasource: 'loki',
@ -151,7 +152,7 @@ describe('Explore: Query History', () => {
};
const { datasources } = setupExplore({ urlParams });
(datasources.loki.query as jest.Mock).mockReturnValue(makeLogsQueryResponse());
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
await waitForExplore();
await waitForExplore(ExploreId.right);
@ -188,7 +189,7 @@ describe('Explore: Query History', () => {
};
const { datasources } = setupExplore({ urlParams });
(datasources.loki.query as jest.Mock).mockReturnValueOnce(makeLogsQueryResponse());
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
await waitForExplore();
await openQueryHistory();
await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.left);
@ -222,7 +223,7 @@ describe('Explore: Query History', () => {
it('pagination', async () => {
config.queryHistoryEnabled = true;
const { datasources } = setupExplore();
(datasources.loki.query as jest.Mock).mockReturnValueOnce(makeLogsQueryResponse());
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
fetchMock.mockReturnValue(
of({
data: { result: { queryHistory: [{ datasourceUid: 'loki', queries: [{ expr: 'query' }] }], totalCount: 2 } },

View File

@ -3,6 +3,7 @@ import { AnyAction, createAction } from '@reduxjs/toolkit';
import { DataSourceApi, HistoryItem } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { DataSourceRef } from '@grafana/schema';
import { RefreshPicker } from '@grafana/ui';
import { stopQueryState } from 'app/core/utils/explore';
import { ExploreItemState, ThunkResult } from 'app/types';
@ -39,12 +40,12 @@ export const updateDatasourceInstanceAction = createAction<UpdateDatasourceInsta
*/
export function changeDatasource(
exploreId: ExploreId,
datasourceUid: string,
datasource: string | DataSourceRef,
options?: { importQueries: boolean }
): ThunkResult<Promise<void>> {
return async (dispatch, getState) => {
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;
reportInteraction('explore_change_ds', {
@ -66,12 +67,12 @@ export function changeDatasource(
}
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
if (options?.importQueries) {
dispatch(runQueries(exploreId));
dispatch(runQueries({ exploreId }));
}
};
}
@ -106,7 +107,6 @@ export const datasourceReducer = (state: ExploreItemState, action: AnyAction): E
loading: false,
queryKeys: [],
history,
datasourceMissing: false,
};
}

View File

@ -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());
});
});

View File

@ -1,41 +1,26 @@
import { createAction, PayloadAction } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import { AnyAction } from 'redux';
import {
EventBusExtended,
ExploreUrlState,
TimeRange,
HistoryItem,
DataSourceApi,
ExplorePanelsState,
PreferredVisualisationType,
RawTimeRange,
} from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { DataQuery, DataSourceRef } from '@grafana/schema';
import {
DEFAULT_RANGE,
getQueryKeys,
parseUrlState,
ensureQueries,
generateNewKeyAndAddRefIdIfMissing,
getTimeRangeFromUrl,
} from 'app/core/utils/explore';
import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors';
import { getQueryKeys } from 'app/core/utils/explore';
import { getTimeZone } from 'app/features/profile/state/selectors';
import { createAsyncThunk, ThunkResult } from 'app/types';
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { datasourceReducer } from './datasource';
import { historyReducer } from './history';
import { richHistorySearchFiltersUpdatedAction, richHistoryUpdatedAction, stateSave } from './main';
import { queryReducer, runQueries, setQueriesAction } from './query';
import { richHistorySearchFiltersUpdatedAction, richHistoryUpdatedAction } from './main';
import { queryReducer, runQueries } from './query';
import { timeReducer, updateTime } from './time';
import {
makeExplorePaneState,
loadAndInitDatasource,
createEmptyQueryResponse,
getUrlStateFromPaneState,
} from './utils';
import { makeExplorePaneState, loadAndInitDatasource, createEmptyQueryResponse, getRange } from './utils';
// Types
//
@ -49,7 +34,6 @@ import {
export interface ChangeSizePayload {
exploreId: ExploreId;
width: number;
height: number;
}
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 {
exploreId: ExploreId;
containerWidth: number;
eventBridge: EventBusExtended;
queries: DataQuery[];
range: TimeRange;
history: HistoryItem[];
datasourceInstance?: DataSourceApi;
isFromCompactUrl?: boolean;
}
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.
* The width will be used to calculate graph intervals (number of datapoints).
*/
export function changeSize(
exploreId: ExploreId,
{ height, width }: { height: number; width: number }
): PayloadAction<ChangeSizePayload> {
return changeSizeAction({ exploreId, height, width });
export function changeSize(exploreId: ExploreId, { width }: { width: number }): PayloadAction<ChangeSizePayload> {
return changeSizeAction({ exploreId, width });
}
interface InitializeExploreOptions {
exploreId: ExploreId;
datasource: DataSourceRef | string;
datasource: DataSourceRef | string | undefined;
queries: DataQuery[];
range: TimeRange;
containerWidth: number;
eventBridge: EventBusExtended;
range: RawTimeRange;
panelsState?: ExplorePanelsState;
isFromCompactUrl?: boolean;
}
/**
* Initialize Explore state with state from the URL and the React component.
@ -138,23 +112,13 @@ interface InitializeExploreOptions {
export const initializeExplore = createAsyncThunk(
'explore/initializeExplore',
async (
{
exploreId,
datasource,
queries,
range,
containerWidth,
eventBridge,
panelsState,
isFromCompactUrl,
}: InitializeExploreOptions,
{ dispatch, getState }
{ exploreId, datasource, queries, range, panelsState }: InitializeExploreOptions,
{ dispatch, getState, fulfillWithValue }
) => {
const exploreDatasources = getDataSourceSrv().getList();
let instance = undefined;
let history: HistoryItem[] = [];
if (exploreDatasources.length >= 1) {
if (datasource) {
const orgId = getState().user.orgId;
const loadResult = await loadAndInitDatasource(orgId, datasource);
instance = loadResult.instance;
@ -164,13 +128,10 @@ export const initializeExplore = createAsyncThunk(
dispatch(
initializeExploreAction({
exploreId,
containerWidth,
eventBridge,
queries,
range,
range: getRange(range, getTimeZone(getState().user)),
datasourceInstance: instance,
history,
isFromCompactUrl,
})
);
if (panelsState !== undefined) {
@ -179,81 +140,13 @@ export const initializeExplore = createAsyncThunk(
dispatch(updateTime({ exploreId }));
if (instance) {
// We do not want to add the url to browser history on init because when the pane is initialised it's because
// 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 }));
dispatch(runQueries({ exploreId }));
}
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.
*/
@ -296,51 +189,20 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac
}
if (initializeExploreAction.match(action)) {
const { containerWidth, eventBridge, queries, range, datasourceInstance, history, isFromCompactUrl } =
action.payload;
const { queries, range, datasourceInstance, history } = action.payload;
return {
...state,
containerWidth,
eventBridge,
range,
queries,
initialized: true,
queryKeys: getQueryKeys(queries),
datasourceInstance,
history,
datasourceMissing: !datasourceInstance,
queryResponse: createEmptyQueryResponse(),
cache: [],
isFromCompactUrl: isFromCompactUrl || false,
};
}
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,
};
};

View File

@ -9,7 +9,7 @@ import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { MockDataSourceApi } from '../../../../test/mocks/datasource_srv';
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 url = '/explore';
@ -136,7 +136,7 @@ describe('Explore reducer', () => {
// closing left item
reducerTester<ExploreState>()
.givenReducer(exploreReducer, initialState)
.whenActionIsDispatched(splitCloseAction({ itemId: ExploreId.left }))
.whenActionIsDispatched(splitClose(ExploreId.left))
.thenStateShouldEqual({
evenSplitPanes: true,
largerExploreId: undefined,
@ -166,7 +166,7 @@ describe('Explore reducer', () => {
// closing left item
reducerTester<ExploreState>()
.givenReducer(exploreReducer, initialState)
.whenActionIsDispatched(splitCloseAction({ itemId: ExploreId.right }))
.whenActionIsDispatched(splitClose(ExploreId.right))
.thenStateShouldEqual({
evenSplitPanes: true,
largerExploreId: undefined,
@ -193,7 +193,7 @@ describe('Explore reducer', () => {
reducerTester<ExploreState>()
.givenReducer(exploreReducer, initialState)
.whenActionIsDispatched(splitCloseAction({ itemId: ExploreId.right }))
.whenActionIsDispatched(splitClose(ExploreId.right))
.thenStateShouldEqual({
evenSplitPanes: true,
panes: {

View File

@ -1,9 +1,9 @@
import { createAction } from '@reduxjs/toolkit';
import { AnyAction } from 'redux';
import { ExploreUrlState, serializeStateToUrlParam, SplitOpenOptions, UrlQueryMap } from '@grafana/data';
import { SplitOpenOptions } from '@grafana/data';
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 { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore';
@ -12,9 +12,10 @@ import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/uti
import { createAsyncThunk, ThunkResult } from '../../../types';
import { CorrelationData } from '../../correlations/useCorrelations';
import { TimeSrv } from '../../dashboard/services/TimeSrv';
import { withUniqueRefIds } from '../utils/queries';
import { initializeExplore, paneReducer } from './explorePane';
import { getUrlStateFromPaneState, makeExplorePaneState } from './utils';
import { DEFAULT_RANGE, makeExplorePaneState } from './utils';
//
// Actions and Payloads
@ -50,90 +51,43 @@ export const maximizePaneAction = createAction<{
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');
/**
* Close the split view and save URL state.
*/
export interface SplitCloseActionPayload {
itemId: ExploreId;
export interface SetPaneStateActionPayload {
[itemId: string]: Partial<ExploreItemState>;
}
export const splitCloseAction = createAction<SplitCloseActionPayload>('explore/splitClose');
export const setPaneState = createAction<SetPaneStateActionPayload>('explore/setPaneState');
//
// Action creators
//
export const clearPanes = createAction('explore/clearPanes');
/**
* Save local redux state back to the URL. Should be called when there is some change that should affect the URL.
* Not all of the redux state is reflected in URL though.
*/
export const stateSave = (options?: { replace?: boolean }): ThunkResult<void> => {
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.
* Opens a new split pane. It either copies existing state of the left pane
* or uses values from options arg.
*
* TODO: this can be improved by better inferring fallback values.
*/
export const splitOpen = createAsyncThunk(
'explore/splitOpen',
async (options: SplitOpenOptions | undefined, { getState }) => {
const leftState: ExploreItemState = getState().explore.panes.left!;
const leftUrlState = getUrlStateFromPaneState(leftState);
let rightUrlState: ExploreUrlState = leftUrlState;
async (options: SplitOpenOptions | undefined, { getState, dispatch }) => {
const leftState = getState().explore.panes.left;
if (options) {
const { query, queries } = options;
const queries = options?.queries ?? (options?.query ? [options?.query] : leftState?.queries || []);
rightUrlState = {
datasource: options.datasourceUid,
queries: queries ?? (query ? [query] : []),
range: options.range || leftState.range,
panelsState: options.panelsState,
};
}
const urlState = serializeStateToUrlParam(rightUrlState);
locationService.partial({ right: urlState }, true);
await dispatch(
initializeExplore({
exploreId: ExploreId.right,
datasource: options?.datasourceUid || leftState?.datasourceInstance?.getRef(),
queries: withUniqueRefIds(queries),
range: options?.range || leftState?.range.raw || DEFAULT_RANGE,
panelsState: options?.panelsState || leftState?.panelsState,
})
);
}
);
/**
* 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 {
getDataSourceSrv: () => DataSourceSrv;
getTimeSrv: () => TimeSrv;
@ -169,9 +123,7 @@ export const navigateToExplore = (
const initialExploreItemState = makeExplorePaneState();
export const initialExploreState: ExploreState = {
syncedTimes: false,
panes: {
[ExploreId.left]: initialExploreItemState,
},
panes: {},
correlations: undefined,
richHistoryStorageFull: false,
richHistoryLimitExceededWarningShown: false,
@ -185,10 +137,9 @@ export const initialExploreState: ExploreState = {
* Actions that have an `exploreId` get routed to the ExploreItemReducer.
*/
export const exploreReducer = (state = initialExploreState, action: AnyAction): ExploreState => {
if (splitCloseAction.match(action)) {
const { itemId } = action.payload;
if (splitClose.match(action)) {
const panes = {
left: itemId === ExploreId.left ? state.panes.right : state.panes.left,
left: action.payload === ExploreId.left ? state.panes.right : state.panes.left,
};
return {
...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)) {
const richHistorySettings = action.payload;
return {
@ -299,21 +233,26 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction):
};
}
if (action.payload) {
const { exploreId } = action.payload;
if (exploreId !== undefined) {
return {
...state,
panes: Object.entries(state.panes).reduce<ExploreState['panes']>((acc, [id, pane]) => {
if (id === exploreId) {
acc[id as ExploreId] = paneReducer(pane, action);
} else {
acc[id as ExploreId] = pane;
}
return acc;
}, {}),
};
}
if (clearPanes.match(action)) {
return {
...state,
panes: {},
};
}
const exploreId: ExploreId | undefined = action.payload?.exploreId;
if (typeof exploreId === 'string') {
return {
...state,
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;

View File

@ -158,7 +158,7 @@ describe('runQueries', () => {
const { dispatch, getState } = setupTests();
setupQueryResponse(getState());
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!.graphResult).toBeDefined();
});
@ -167,7 +167,7 @@ describe('runQueries', () => {
const { dispatch, getState } = setupTests();
setupQueryResponse(getState());
dispatch(saveCorrelationsAction([]));
dispatch(runQueries(ExploreId.left));
dispatch(runQueries({ exploreId: ExploreId.left }));
const state = getState().explore.panes.left!;
expect(state.queryResponse.request?.requestId).toBe('explore_left');
@ -187,7 +187,7 @@ describe('runQueries', () => {
const leftDatasourceInstance = assertIsDefined(getState().explore.panes.left!.datasourceInstance);
jest.mocked(leftDatasourceInstance.query).mockReturnValueOnce(EMPTY);
await dispatch(saveCorrelationsAction([]));
await dispatch(runQueries(ExploreId.left));
await dispatch(runQueries({ exploreId: ExploreId.left }));
await new Promise((resolve) => setTimeout(() => resolve(''), 500));
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 () => {
const { dispatch, getState } = setupTests();
setupQueryResponse(getState());
await dispatch(runQueries(ExploreId.left));
await dispatch(runQueries({ exploreId: ExploreId.left }));
expect(getState().explore.panes.left!.graphResult).not.toBeDefined();
await dispatch(saveCorrelationsAction([]));
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 () => {
dispatch(runQueries(ExploreId.left));
dispatch(runQueries({ exploreId: ExploreId.left }));
// first query is run automatically
// loading in progress - subscriptions for both supplementary queries are created, not cleaned up yet
expect(unsubscribes).toHaveLength(2);
@ -799,7 +799,7 @@ describe('reducer', () => {
expect(unsubscribes[1]).not.toBeCalled();
setupQueryResponse(getState());
dispatch(runQueries(ExploreId.left));
dispatch(runQueries({ exploreId: ExploreId.left }));
// a new query is run while supplementary queries are not resolve yet...
expect(unsubscribes[0]).toBeCalled();
expect(unsubscribes[1]).toBeCalled();
@ -810,7 +810,7 @@ describe('reducer', () => {
});
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[0]).not.toBeCalled();
expect(unsubscribes[1]).not.toBeCalled();
@ -827,7 +827,7 @@ describe('reducer', () => {
});
it('should load supplementary queries after running the query', () => {
dispatch(runQueries(ExploreId.left));
dispatch(runQueries({ exploreId: ExploreId.left }));
expect(unsubscribes).toHaveLength(2);
});
@ -835,7 +835,7 @@ describe('reducer', () => {
mockDataProvider = () => {
return of({ state: LoadingState.Loading, error: undefined, data: [] });
};
dispatch(runQueries(ExploreId.left));
dispatch(runQueries({ exploreId: ExploreId.left }));
for (const type of supplementaryQueryTypes) {
expect(getState().explore.panes.left!.supplementaryQueries[type].data).toBeDefined();
@ -862,7 +862,7 @@ describe('reducer', () => {
{ state: LoadingState.Done, error: undefined, data: [{}] }
);
};
dispatch(runQueries(ExploreId.left));
dispatch(runQueries({ exploreId: ExploreId.left }));
for (const types of supplementaryQueryTypes) {
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);
// 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(
getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].data
@ -918,7 +918,7 @@ describe('reducer', () => {
dispatch(setSupplementaryQueryEnabled(ExploreId.left, false, SupplementaryQueryType.LogsSample));
// runQueries sets up providers, but does not run queries
dispatch(runQueries(ExploreId.left));
dispatch(runQueries({ exploreId: ExploreId.left }));
expect(
getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].dataProvider
).toBeDefined();

View File

@ -59,14 +59,9 @@ import {
} from '../utils/supplementaryQueries';
import { addHistoryItem, historyUpdatedAction, loadRichHistory } from './history';
import { stateSave } from './main';
import { updateTime } from './time';
import { createCacheKey, getResultsFromCache, filterLogRowsByIndex } from './utils';
//
// Actions and Payloads
//
/**
* 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');
//
// Action creators
//
/**
* 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(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 (queries.length < queries.length) {
dispatch(runQueries(exploreId));
if (queries.length < oldQueries.length) {
dispatch(runQueries({ exploreId }));
}
}
);
@ -435,7 +425,7 @@ export function modifyQueries(
dispatch(setQueriesAction({ exploreId, queries: nextQueries }));
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
*/
export const runQueries = (
exploreId: ExploreId,
options?: { replaceUrl?: boolean; preserveCache?: boolean }
): ThunkResult<void> => {
return (dispatch, getState) => {
export const runQueries = createAsyncThunk<void, RunQueriesOptions>(
'explore/runQueries',
async ({ exploreId, preserveCache }, { dispatch, getState }) => {
dispatch(updateTime({ exploreId }));
const correlations$ = getCorrelations();
// We always want to clear cache unless we explicitly pass preserveCache parameter
const preserveCache = options?.preserveCache === true;
if (!preserveCache) {
if (preserveCache !== true) {
dispatch(clearCache(exploreId));
}
@ -506,8 +497,6 @@ export const runQueries = (
handleHistory(dispatch, getState().explore, exploreItemState.history, datasourceInstance, queries, exploreId);
}
dispatch(stateSave({ replace: options?.replaceUrl }));
const cachedValue = getResultsFromCache(cache, absoluteRange);
// 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) => {
if (!data.error) {
dispatch(stateSave());
}
dispatch(queryStreamUpdatedAction({ exploreId, response: data }));
});
// If we don't have results saved in cache, run new queries
} else {
if (!hasNonEmptyQuery(queries)) {
dispatch(stateSave({ replace: options?.replaceUrl })); // Remember to save to state and update location
return;
}
if (!datasourceInstance) {
if (!hasNonEmptyQuery(queries) || !datasourceInstance) {
return;
}
@ -587,7 +567,7 @@ export const runQueries = (
if (data.state === LoadingState.Done && data.series.length === 0) {
const range = getShiftedTimeRange(-1, getState().explore.panes[exploreId]!.range);
dispatch(updateTime({ exploreId, absoluteRange: range }));
dispatch(runQueries(exploreId));
dispatch(runQueries({ exploreId }));
} else {
// We can stop scanning if we have a result
dispatch(scanStopAction({ exploreId }));
@ -635,8 +615,8 @@ export const runQueries = (
}
dispatch(queryStoreSubscriptionAction({ exploreId, querySubscription: newQuerySubscription }));
};
};
}
);
const groupDataQueries = async (datasources: DataQuery[], scopedVars: ScopedVars) => {
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 nextQueries = rawQueries.map((query, index) => generateNewKeyAndAddRefIdIfMissing(query, queries, index));
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);
// Set the new range to be displayed
dispatch(updateTime({ exploreId, absoluteRange: range }));
dispatch(runQueries(exploreId));
dispatch(runQueries({ exploreId }));
};
}

View 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']);
});
});

View File

@ -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]);

View File

@ -4,10 +4,8 @@ import { dateTime, LoadingState } from '@grafana/data';
import { configureStore } from 'app/store/configureStore';
import { ExploreId, ExploreItemState } from 'app/types';
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput';
import { createDefaultInitialState } from './helpers';
import { changeRangeAction, changeRefreshIntervalAction, timeReducer, updateTime } from './time';
import { changeRangeAction, changeRefreshInterval, timeReducer, updateTime } from './time';
import { makeExplorePaneState } from './utils';
const MOCK_TIME_RANGE = {};
@ -30,14 +28,10 @@ jest.mock('@grafana/runtime', () => ({
}));
describe('Explore item reducer', () => {
silenceConsoleOutput();
describe('When time is updated', () => {
it('Time service is re-initialized and template service is updated with the new time range', async () => {
const { dispatch } = configureStore({
...(createDefaultInitialState() as any),
});
await dispatch(updateTime({ exploreId: ExploreId.left }));
const { dispatch } = configureStore(createDefaultInitialState().defaultInitialState as any);
dispatch(updateTime({ exploreId: ExploreId.left }));
expect(mockTimeSrv.init).toBeCalled();
expect(mockTemplateSrv.updateTimeRange).toBeCalledWith(MOCK_TIME_RANGE);
});
@ -62,7 +56,7 @@ describe('Explore item reducer', () => {
};
reducerTester<ExploreItemState>()
.givenReducer(timeReducer, initialState)
.whenActionIsDispatched(changeRefreshIntervalAction({ exploreId: ExploreId.left, refreshInterval: 'LIVE' }))
.whenActionIsDispatched(changeRefreshInterval({ exploreId: ExploreId.left, refreshInterval: 'LIVE' }))
.thenStateShouldEqual(expectedState);
});
@ -82,7 +76,7 @@ describe('Explore item reducer', () => {
};
reducerTester<ExploreItemState>()
.givenReducer(timeReducer, initialState)
.whenActionIsDispatched(changeRefreshIntervalAction({ exploreId: ExploreId.left, refreshInterval: '' }))
.whenActionIsDispatched(changeRefreshInterval({ exploreId: ExploreId.left, refreshInterval: '' }))
.thenStateShouldEqual(expectedState);
});
});

View File

@ -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 { getTemplateSrv } from '@grafana/runtime';
@ -12,7 +12,7 @@ import { ExploreId } from 'app/types/explore';
import { getTimeSrv } from '../../dashboard/services/TimeSrv';
import { TimeModel } from '../../dashboard/state/TimeModel';
import { syncTimesAction, stateSave } from './main';
import { syncTimesAction } from './main';
import { runQueries } from './query';
//
@ -33,7 +33,7 @@ export interface ChangeRefreshIntervalPayload {
exploreId: ExploreId;
refreshInterval: string;
}
export const changeRefreshIntervalAction = createAction<ChangeRefreshIntervalPayload>('explore/changeRefreshInterval');
export const changeRefreshInterval = createAction<ChangeRefreshIntervalPayload>('explore/changeRefreshInterval');
export const updateTimeRange = (options: {
exploreId: ExploreId;
@ -45,25 +45,15 @@ export const updateTimeRange = (options: {
if (syncedTimes) {
Object.keys(getState().explore.panes).forEach((exploreId) => {
dispatch(updateTime({ ...options, exploreId: exploreId as ExploreId }));
dispatch(runQueries(exploreId as ExploreId, { preserveCache: true }));
dispatch(runQueries({ exploreId: exploreId as ExploreId, preserveCache: true }));
});
} else {
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: {
exploreId: ExploreId;
rawRange?: RawTimeRange;
@ -126,7 +116,6 @@ export function syncTimes(exploreId: ExploreId): ThunkResult<void> {
const isTimeSynced = getState().explore.syncedTimes;
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() };
dispatch(updateTime({ exploreId: exploreId as ExploreId, absoluteRange }));
});
dispatch(stateSave());
};
}
@ -159,7 +146,7 @@ export function makeAbsoluteTime(): ThunkResult<void> {
// the frozen state.
// https://github.com/reduxjs/redux-toolkit/issues/242
export const timeReducer = (state: ExploreItemState, action: AnyAction): ExploreItemState => {
if (changeRefreshIntervalAction.match(action)) {
if (changeRefreshInterval.match(action)) {
const { refreshInterval } = action.payload;
const live = RefreshPicker.isLive(refreshInterval);
const sortOrder = refreshIntervalToSortOrder(refreshInterval);

View File

@ -1,5 +1,5 @@
import store from '../../../core/store';
import { lastUsedDatasourceKeyForOrgId } from '../../../core/utils/explore';
import { dateTime } from '@grafana/data';
import * as exploreUtils from 'app/core/utils/explore';
const dataSourceMock = {
get: jest.fn(),
@ -8,19 +8,24 @@ jest.mock('app/features/plugins/datasource_srv', () => ({
getDatasourceSrv: jest.fn(() => dataSourceMock),
}));
jest.spyOn(store, 'set');
import { loadAndInitDatasource } from './utils';
import { loadAndInitDatasource, getRange } from './utils';
const DEFAULT_DATASOURCE = { uid: 'abc123', name: 'Default' };
const TEST_DATASOURCE = { uid: 'def789', name: 'Test' };
describe('loadAndInitDatasource', () => {
beforeEach(() => {
let setLastUsedDatasourceUIDSpy;
afterEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
jest.restoreAllMocks();
});
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.mockResolvedValue(DEFAULT_DATASOURCE);
@ -30,10 +35,11 @@ describe('loadAndInitDatasource', () => {
expect(dataSourceMock.get).toBeCalledWith({ uid: 'Unknown' });
expect(dataSourceMock.get).toBeCalledWith();
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 () => {
setLastUsedDatasourceUIDSpy = jest.spyOn(exploreUtils, 'setLastUsedDatasourceUID');
dataSourceMock.get.mockResolvedValue(TEST_DATASOURCE);
const { instance } = await loadAndInitDatasource(1, { uid: 'Test' });
@ -41,6 +47,39 @@ describe('loadAndInitDatasource', () => {
expect(dataSourceMock.get).toBeCalledTimes(1);
expect(dataSourceMock.get).toBeCalledWith({ uid: 'Test' });
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());
});
});

View File

@ -1,25 +1,28 @@
import { isEmpty, isObject, mapValues, omitBy } from 'lodash';
import {
AbsoluteTimeRange,
DataSourceApi,
EventBusExtended,
ExploreUrlState,
getDefaultTimeRange,
HistoryItem,
LoadingState,
LogRowModel,
PanelData,
RawTimeRange,
TimeFragment,
TimeRange,
dateMath,
DateTime,
isDateTime,
toUtc,
} from '@grafana/data';
import { DataSourceRef } from '@grafana/schema';
import { DataSourceRef, TimeZone } from '@grafana/schema';
import { ExplorePanelData } from 'app/types';
import { ExploreItemState } from 'app/types/explore';
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 { loadSupplementaryQueries } from '../utils/supplementaryQueries';
import { toRawTimeRange } from '../utils/time';
export const DEFAULT_RANGE = {
from: 'now-6h',
@ -37,7 +40,6 @@ export const storeGraphStyle = (graphStyle: string): void => {
export const makeExplorePaneState = (): ExploreItemState => ({
containerWidth: 0,
datasourceInstance: null,
datasourceMissing: false,
history: [],
queries: [],
initialized: false,
@ -112,33 +114,10 @@ export async function loadAndInitDatasource(
const history = store.getObject<HistoryItem[]>(historyKey, []);
// Save last-used datasource
store.set(lastUsedDatasourceKeyForOrgId(orgId), instance.uid);
setLastUsedDatasourceUID(orgId, instance.uid);
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) {
const params = {
from: absRange.from,
@ -161,6 +140,57 @@ export function getResultsFromCache(
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 = (
clearedAtIndex: ExploreItemState['clearedAtIndex'],
logRows?: LogRowModel[]

View File

@ -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
// great. Needs a bit of refactoring.
dispatch(changeRefreshInterval(exploreId, RefreshPicker.offOption.value));
dispatch(runQueries(exploreId));
dispatch(changeRefreshInterval({ exploreId, refreshInterval: RefreshPicker.offOption.value }));
dispatch(runQueries({ exploreId }));
}, [exploreId, dispatch, pause]);
const start = useCallback(() => {
dispatch(changeRefreshInterval(exploreId, RefreshPicker.liveOption.value));
dispatch(changeRefreshInterval({ exploreId, refreshInterval: RefreshPicker.liveOption.value }));
}, [exploreId, dispatch]);
const clear = useCallback(() => {

View 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;
});
}

View File

@ -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,
};
};

View File

@ -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 { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
@ -17,11 +17,14 @@ export function addRootReducer(reducers: any) {
addReducer(reducers);
}
const listenerMiddleware = createListenerMiddleware();
export function configureStore(initialState?: Partial<StoreState>) {
const store = reduxConfigureStore({
reducer: createRootReducer(),
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }).concat(
listenerMiddleware.middleware,
alertingApi.middleware,
publicDashboardApi.middleware,
browseDashboardsAPI.middleware

View File

@ -26,8 +26,8 @@ export enum ExploreId {
}
export type ExploreQueryParams = {
left: string;
right: string;
left?: 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.
*/
datasourceInstance?: DataSourceApi | null;
/**
* True if there is no datasource to be selected.
*/
datasourceMissing: boolean;
/**
* Emitter to send events to the rest of Grafana.
*/
@ -211,8 +207,6 @@ export interface ExploreItemState {
supplementaryQueries: SupplementaryQueries;
panelsState: ExplorePanelsState;
isFromCompactUrl?: boolean;
}
export interface ExploreUpdateState {

View File

@ -1,11 +1,13 @@
/* eslint-disable no-restricted-imports */
import {
Action,
addListener as addListenerUntyped,
AsyncThunk,
AsyncThunkOptions,
AsyncThunkPayloadCreator,
createAsyncThunk as createAsyncThunkUntyped,
PayloadAction,
TypedAddListener,
} from '@reduxjs/toolkit';
import {
useSelector as useSelectorUntyped,
@ -37,3 +39,5 @@ export const createAsyncThunk = <Returned, ThunkArg = void, ThunkApiConfig exten
options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
): AsyncThunk<Returned, ThunkArg, ThunkApiConfig> =>
createAsyncThunkUntyped<Returned, ThunkArg, ThunkApiConfig>(typePrefix, payloadCreator, options);
export const addListener = addListenerUntyped as TypedAddListener<RootState, AppDispatch>;