Explore: URL migrations & improved state management (#69692)

Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>
This commit is contained in:
Giordano Ricci 2023-06-21 10:06:28 +01:00 committed by GitHub
parent a489135825
commit d3450d75a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
82 changed files with 1562 additions and 1178 deletions

View File

@ -1675,8 +1675,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"]
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
], ],
"public/app/core/utils/fetch.ts:5381": [ "public/app/core/utils/fetch.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],
@ -2504,9 +2503,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"] [0, 0, 0, "Unexpected any. Specify a different type.", "1"]
], ],
"public/app/features/explore/ExplorePage.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/explore/ExploreQueryInspector.tsx:5381": [ "public/app/features/explore/ExploreQueryInspector.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]
], ],
@ -2567,11 +2563,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"] [0, 0, 0, "Do not use any type assertions.", "1"]
], ],
"public/app/features/explore/hooks/useStateSync.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"]
],
"public/app/features/explore/spec/helper/setup.tsx:5381": [ "public/app/features/explore/spec/helper/setup.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"] [0, 0, 0, "Unexpected any. Specify a different type.", "1"]
@ -2582,24 +2573,9 @@ exports[`better eslint`] = {
"public/app/features/explore/spec/queryHistory.test.tsx:5381": [ "public/app/features/explore/spec/queryHistory.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"] [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
], ],
"public/app/features/explore/state/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"]
],
"public/app/features/explore/state/query.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/explore/state/time.test.ts:5381": [ "public/app/features/explore/state/time.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"] [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
], ],
"public/app/features/explore/state/time.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"],
[0, 0, 0, "Do not use any type assertions.", "3"]
],
"public/app/features/explore/state/utils.ts:5381": [ "public/app/features/explore/state/utils.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"],

View File

@ -28,34 +28,12 @@ e2e.scenario({
cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click(); cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click();
cy.location().then((loc) => {
const params = new URLSearchParams(loc.search);
const leftJSON = JSON.parse(params.get('left'));
expect(leftJSON.range.to).to.equal('now');
expect(leftJSON.range.from).to.equal('now-1h');
cy.get('body').click();
cy.get('body').type('t{leftarrow}');
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
expect(leftJSON.range.to).to.not.equal('now');
expect(leftJSON.range.from).to.not.equal('now-1h');
// be sure the url does not contain dashboard range values
// eslint wants this to be a function, so we use this instead of to.be.false
expect(params.has('to')).to.equal(false);
expect(params.has('from')).to.equal(false);
});
});
const canvases = e2e().get('canvas'); const canvases = e2e().get('canvas');
canvases.should('have.length', 1); canvases.should('have.length', 1);
// Both queries above should have been run and be shown in the query history // Both queries above should have been run and be shown in the query history
e2e.components.QueryTab.queryHistoryButton().should('be.visible').click(); e2e.components.QueryTab.queryHistoryButton().should('be.visible').click();
e2e.components.QueryHistory.queryText().should('have.length', 2).should('contain', 'csv_metric_values'); e2e.components.QueryHistory.queryText().should('have.length', 1).should('contain', 'csv_metric_values');
// delete all queries // delete all queries
cy.get('button[title="Delete query"]').each((button) => { cy.get('button[title="Delete query"]').each((button) => {

View File

@ -1,15 +1,15 @@
import { DataQuery } from '@grafana/schema';
import { PreferredVisualisationType } from './data'; import { PreferredVisualisationType } from './data';
import { DataQuery } from './query';
import { RawTimeRange, TimeRange } from './time'; import { RawTimeRange, TimeRange } from './time';
type AnyQuery = DataQuery & Record<string, any>; type AnyQuery = DataQuery & Record<string, any>;
/** @internal */ /** @internal */
export interface ExploreUrlState<T extends DataQuery = AnyQuery> { export interface ExploreUrlState<T extends DataQuery = AnyQuery> {
datasource: string; datasource: string | null;
queries: T[]; queries: T[];
range: RawTimeRange; range: RawTimeRange;
context?: string;
panelsState?: ExplorePanelsState; panelsState?: ExplorePanelsState;
} }

View File

@ -2,15 +2,13 @@ import { dateTime, ExploreUrlState, LogsSortOrder } from '@grafana/data';
import { serializeStateToUrlParam } from '@grafana/data/src/utils/url'; import { serializeStateToUrlParam } from '@grafana/data/src/utils/url';
import { RefreshPicker } from '@grafana/ui'; import { RefreshPicker } from '@grafana/ui';
import store from 'app/core/store'; import store from 'app/core/store';
import { ExploreId } from 'app/types'; import { DEFAULT_RANGE } from 'app/features/explore/state/utils';
import { DatasourceSrvMock, MockDataSourceApi } from '../../../test/mocks/datasource_srv'; import { DatasourceSrvMock, MockDataSourceApi } from '../../../test/mocks/datasource_srv';
import { import {
buildQueryTransaction, buildQueryTransaction,
DEFAULT_RANGE,
hasNonEmptyQuery, hasNonEmptyQuery,
parseUrlState,
refreshIntervalToSortOrder, refreshIntervalToSortOrder,
updateHistory, updateHistory,
getExploreUrl, getExploreUrl,
@ -42,67 +40,6 @@ jest.mock('@grafana/runtime', () => ({
})); }));
describe('state functions', () => { describe('state functions', () => {
describe('parseUrlState', () => {
it('returns default state on empty string', () => {
expect(parseUrlState('')).toMatchObject({
datasource: null,
queries: [],
range: DEFAULT_RANGE,
});
});
it('returns a valid Explore state from URL parameter', () => {
const paramValue = '{"datasource":"Local","queries":[{"expr":"metric"}],"range":{"from":"now-1h","to":"now"}}';
expect(parseUrlState(paramValue)).toMatchObject({
datasource: 'Local',
queries: [{ expr: 'metric' }],
range: {
from: 'now-1h',
to: 'now',
},
});
});
it('returns a valid Explore state from a compact URL parameter', () => {
const paramValue = '["now-1h","now","Local",{"expr":"metric"},{"ui":[true,true,true,"none"]}]';
expect(parseUrlState(paramValue)).toMatchObject({
datasource: 'Local',
queries: [{ expr: 'metric' }],
range: {
from: 'now-1h',
to: 'now',
},
});
});
it('should not return a query for mode in the url', () => {
// Previous versions of Grafana included "Explore mode" in the URL; this should not be treated as a query.
const paramValue =
'["now-1h","now","x-ray-datasource",{"queryType":"getTraceSummaries"},{"mode":"Metrics"},{"ui":[true,true,true,"none"]}]';
expect(parseUrlState(paramValue)).toMatchObject({
datasource: 'x-ray-datasource',
queries: [{ queryType: 'getTraceSummaries' }],
range: {
from: 'now-1h',
to: 'now',
},
});
});
it('should return queries if queryType is present in the url', () => {
const paramValue =
'["now-1h","now","x-ray-datasource",{"queryType":"getTraceSummaries"},{"ui":[true,true,true,"none"]}]';
expect(parseUrlState(paramValue)).toMatchObject({
datasource: 'x-ray-datasource',
queries: [{ queryType: 'getTraceSummaries' }],
range: {
from: 'now-1h',
to: 'now',
},
});
});
});
describe('serializeStateToUrlParam', () => { describe('serializeStateToUrlParam', () => {
it('returns url parameter value for a state object', () => { it('returns url parameter value for a state object', () => {
const state = { const state = {
@ -130,61 +67,6 @@ describe('state functions', () => {
); );
}); });
}); });
describe('interplay', () => {
it('can parse the serialized state into the original state', () => {
const state = {
...DEFAULT_EXPLORE_STATE,
datasource: 'foo',
queries: [
{
expr: 'metric{test="a/b"}',
refId: 'A',
},
{
expr: 'super{foo="x/z"}',
refId: 'B',
},
],
range: {
from: 'now - 5h',
to: 'now',
},
};
const serialized = serializeStateToUrlParam(state);
const parsed = parseUrlState(serialized);
expect(state).toMatchObject(parsed);
});
it('can parse serialized panelsState into the original state', () => {
const state = {
...DEFAULT_EXPLORE_STATE,
datasource: 'foo',
queries: [
{
expr: 'metric{test="a/b"}',
refId: 'A',
},
{
expr: 'super{foo="x/z"}',
refId: 'B',
},
],
range: {
from: 'now - 5h',
to: 'now',
},
panelsState: {
trace: {
spanId: 'abcdef',
},
},
};
const serialized = serializeStateToUrlParam(state);
const parsed = parseUrlState(serialized);
expect(state).toMatchObject(parsed);
});
});
}); });
describe('getExploreUrl', () => { describe('getExploreUrl', () => {
@ -304,21 +186,21 @@ describe('when buildQueryTransaction', () => {
const queries = [{ refId: 'A' }]; const queries = [{ refId: 'A' }];
const queryOptions = { maxDataPoints: 1000, minInterval: '15s' }; const queryOptions = { maxDataPoints: 1000, minInterval: '15s' };
const range = { from: dateTime().subtract(1, 'd'), to: dateTime(), raw: { from: '1h', to: '1h' } }; const range = { from: dateTime().subtract(1, 'd'), to: dateTime(), raw: { from: '1h', to: '1h' } };
const transaction = buildQueryTransaction(ExploreId.left, queries, queryOptions, range, false); const transaction = buildQueryTransaction('left', queries, queryOptions, range, false);
expect(transaction.request.intervalMs).toEqual(60000); expect(transaction.request.intervalMs).toEqual(60000);
}); });
it('it should calculate interval taking minInterval into account', () => { it('it should calculate interval taking minInterval into account', () => {
const queries = [{ refId: 'A' }]; const queries = [{ refId: 'A' }];
const queryOptions = { maxDataPoints: 1000, minInterval: '15s' }; const queryOptions = { maxDataPoints: 1000, minInterval: '15s' };
const range = { from: dateTime().subtract(1, 'm'), to: dateTime(), raw: { from: '1h', to: '1h' } }; const range = { from: dateTime().subtract(1, 'm'), to: dateTime(), raw: { from: '1h', to: '1h' } };
const transaction = buildQueryTransaction(ExploreId.left, queries, queryOptions, range, false); const transaction = buildQueryTransaction('left', queries, queryOptions, range, false);
expect(transaction.request.intervalMs).toEqual(15000); expect(transaction.request.intervalMs).toEqual(15000);
}); });
it('it should calculate interval taking maxDataPoints into account', () => { it('it should calculate interval taking maxDataPoints into account', () => {
const queries = [{ refId: 'A' }]; const queries = [{ refId: 'A' }];
const queryOptions = { maxDataPoints: 10, minInterval: '15s' }; const queryOptions = { maxDataPoints: 10, minInterval: '15s' };
const range = { from: dateTime().subtract(1, 'd'), to: dateTime(), raw: { from: '1h', to: '1h' } }; const range = { from: dateTime().subtract(1, 'd'), to: dateTime(), raw: { from: '1h', to: '1h' } };
const transaction = buildQueryTransaction(ExploreId.left, queries, queryOptions, range, false); const transaction = buildQueryTransaction('left', queries, queryOptions, range, false);
expect(transaction.request.interval).toEqual('2h'); expect(transaction.request.interval).toEqual('2h');
}); });
}); });

View File

@ -1,3 +1,4 @@
import { nanoid } from '@reduxjs/toolkit';
import { omit } from 'lodash'; import { omit } from 'lodash';
import { Unsubscribable } from 'rxjs'; import { Unsubscribable } from 'rxjs';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -26,17 +27,12 @@ import store from 'app/core/store';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { PanelModel } from 'app/features/dashboard/state'; import { PanelModel } from 'app/features/dashboard/state';
import { ExpressionDatasourceUID } from 'app/features/expressions/types'; import { ExpressionDatasourceUID } from 'app/features/expressions/types';
import { ExploreId, QueryOptions, QueryTransaction } from 'app/types/explore'; import { QueryOptions, QueryTransaction } from 'app/types/explore';
import { config } from '../config'; import { config } from '../config';
import { getNextRefIdChar } from './query'; import { getNextRefIdChar } from './query';
export const DEFAULT_RANGE = {
from: 'now-1h',
to: 'now',
};
export const DEFAULT_UI_STATE = { export const DEFAULT_UI_STATE = {
dedupStrategy: LogsDedupStrategy.none, dedupStrategy: LogsDedupStrategy.none,
}; };
@ -58,6 +54,10 @@ export interface GetExploreUrlArguments {
timeSrv: TimeSrv; timeSrv: TimeSrv;
} }
export function generateExploreId() {
return nanoid(3);
}
/** /**
* Returns an Explore-URL that contains a panel's queries and the dashboard time range. * Returns an Explore-URL that contains a panel's queries and the dashboard time range.
*/ */
@ -98,28 +98,26 @@ export async function getExploreUrl(args: GetExploreUrlArguments): Promise<strin
const scopedVars = panel.scopedVars || {}; const scopedVars = panel.scopedVars || {};
state = { state = {
...state, ...state,
datasource: exploreDatasource.name, datasource: exploreDatasource.uid,
context: 'explore',
queries: exploreDatasource.interpolateVariablesInQueries(exploreTargets, scopedVars), queries: exploreDatasource.interpolateVariablesInQueries(exploreTargets, scopedVars),
}; };
} else { } else {
state = { state = {
...state, ...state,
datasource: exploreDatasource.name, datasource: exploreDatasource.uid,
context: 'explore',
queries: exploreTargets, queries: exploreTargets,
}; };
} }
const exploreState = JSON.stringify(state); const exploreState = JSON.stringify({ [generateExploreId()]: state });
url = urlUtil.renderUrl('/explore', { left: exploreState }); url = urlUtil.renderUrl('/explore', { panes: exploreState, schemaVersion: 1 });
} }
return url; return url;
} }
export function buildQueryTransaction( export function buildQueryTransaction(
exploreId: ExploreId, exploreId: string,
queries: DataQuery[], queries: DataQuery[],
queryOptions: QueryOptions, queryOptions: QueryOptions,
range: TimeRange, range: TimeRange,
@ -172,16 +170,6 @@ export function buildQueryTransaction(
export const clearQueryKeys: (query: DataQuery) => DataQuery = ({ key, ...rest }) => rest; export const clearQueryKeys: (query: DataQuery) => DataQuery = ({ key, ...rest }) => rest;
const isSegment = (segment: { [key: string]: string }, ...props: string[]) =>
props.some((prop) => segment.hasOwnProperty(prop));
enum ParseUrlStateIndex {
RangeFrom = 0,
RangeTo = 1,
Datasource = 2,
SegmentsStart = 3,
}
export const safeParseJson = (text?: string): any | undefined => { export const safeParseJson = (text?: string): any | undefined => {
if (!text) { if (!text) {
return; return;
@ -208,40 +196,6 @@ export const safeStringifyValue = (value: unknown, space?: number) => {
return ''; return '';
}; };
export function parseUrlState(initial: string | undefined): ExploreUrlState {
const parsed = safeParseJson(initial);
const errorResult: any = {
datasource: null,
queries: [],
range: DEFAULT_RANGE,
mode: null,
};
if (!parsed) {
return errorResult;
}
if (!Array.isArray(parsed)) {
return { queries: [], range: DEFAULT_RANGE, ...parsed };
}
if (parsed.length <= ParseUrlStateIndex.SegmentsStart) {
console.error('Error parsing compact URL state for Explore.');
return errorResult;
}
const range = {
from: parsed[ParseUrlStateIndex.RangeFrom],
to: parsed[ParseUrlStateIndex.RangeTo],
};
const datasource = parsed[ParseUrlStateIndex.Datasource];
const parsedSegments = parsed.slice(ParseUrlStateIndex.SegmentsStart);
const queries = parsedSegments.filter((segment) => !isSegment(segment, 'ui', 'mode', '__panelsState'));
const panelsState = parsedSegments.find((segment) => isSegment(segment, '__panelsState'))?.__panelsState;
return { datasource, queries, range, panelsState };
}
export function generateKey(index = 0): string { export function generateKey(index = 0): string {
return `Q-${uuidv4()}-${index}`; return `Q-${uuidv4()}-${index}`;
} }

View File

@ -134,7 +134,6 @@ export const createUrlFromRichHistory = (query: RichHistoryQuery) => {
range: { from: 'now-1h', to: 'now' }, range: { from: 'now-1h', to: 'now' },
datasource: query.datasourceName, datasource: query.datasourceName,
queries: query.queries, queries: query.queries,
context: 'explore',
}; };
const serializedState = serializeStateToUrlParam(exploreState); const serializedState = serializeStateToUrlParam(exploreState);

View File

@ -8,7 +8,7 @@ import { Alert, Button, Field, InputControl, Modal, RadioButtonGroup } from '@gr
import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; import { DashboardPicker } from 'app/core/components/Select/DashboardPicker';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { removeDashboardToFetchFromLocalStorage } from 'app/features/dashboard/state/initDashboard'; import { removeDashboardToFetchFromLocalStorage } from 'app/features/dashboard/state/initDashboard';
import { ExploreId, AccessControlAction, useSelector } from 'app/types'; import { AccessControlAction, useSelector } from 'app/types';
import { getExploreItemSelector } from '../state/selectors'; import { getExploreItemSelector } from '../state/selectors';
@ -57,7 +57,7 @@ interface SubmissionError {
interface Props { interface Props {
onClose: () => void; onClose: () => void;
exploreId: ExploreId; exploreId: string;
} }
export const AddToDashboardModal = ({ onClose, exploreId }: Props) => { export const AddToDashboardModal = ({ onClose, exploreId }: Props) => {

View File

@ -11,7 +11,7 @@ import { Echo } from 'app/core/services/echo/Echo';
import * as initDashboard from 'app/features/dashboard/state/initDashboard'; import * as initDashboard from 'app/features/dashboard/state/initDashboard';
import { DashboardSearchItemType } from 'app/features/search/types'; import { DashboardSearchItemType } from 'app/features/search/types';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
import { ExploreId, ExploreState } from 'app/types'; import { ExploreState } from 'app/types';
import { createEmptyQueryResponse } from '../state/utils'; import { createEmptyQueryResponse } from '../state/utils';
@ -52,7 +52,7 @@ describe('AddToDashboardButton', () => {
}); });
it('Is disabled if explore pane has no queries', async () => { it('Is disabled if explore pane has no queries', async () => {
setup(<AddToDashboard exploreId={ExploreId.left} />, []); setup(<AddToDashboard exploreId={'left'} />, []);
const button = await screen.findByRole('button', { name: /add to dashboard/i }); const button = await screen.findByRole('button', { name: /add to dashboard/i });
expect(button).toBeDisabled(); expect(button).toBeDisabled();
@ -81,7 +81,7 @@ describe('AddToDashboardButton', () => {
}); });
it('Opens and closes the modal correctly', async () => { it('Opens and closes the modal correctly', async () => {
setup(<AddToDashboard exploreId={ExploreId.left} />); setup(<AddToDashboard exploreId={'left'} />);
await openModal(); await openModal();
@ -96,7 +96,7 @@ describe('AddToDashboardButton', () => {
const openSpy = jest.spyOn(global, 'open').mockReturnValue(true); const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
const pushSpy = jest.spyOn(locationService, 'push'); const pushSpy = jest.spyOn(locationService, 'push');
setup(<AddToDashboard exploreId={ExploreId.left} />); setup(<AddToDashboard exploreId={'left'} />);
await openModal(); await openModal();
@ -115,7 +115,7 @@ describe('AddToDashboardButton', () => {
const openSpy = jest.spyOn(global, 'open').mockReturnValue(true); const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
const pushSpy = jest.spyOn(locationService, 'push'); const pushSpy = jest.spyOn(locationService, 'push');
setup(<AddToDashboard exploreId={ExploreId.left} />); setup(<AddToDashboard exploreId={'left'} />);
await openModal(); await openModal();
@ -134,7 +134,7 @@ describe('AddToDashboardButton', () => {
// @ts-expect-error global.open should return a Window, but is not implemented in js-dom. // @ts-expect-error global.open should return a Window, but is not implemented in js-dom.
const openSpy = jest.spyOn(global, 'open').mockReturnValue(true); const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
setup(<AddToDashboard exploreId={ExploreId.left} />); setup(<AddToDashboard exploreId={'left'} />);
await openModal(); await openModal();
@ -148,7 +148,7 @@ describe('AddToDashboardButton', () => {
it('Navigates to the new dashboard', async () => { it('Navigates to the new dashboard', async () => {
const pushSpy = jest.spyOn(locationService, 'push'); const pushSpy = jest.spyOn(locationService, 'push');
setup(<AddToDashboard exploreId={ExploreId.left} />); setup(<AddToDashboard exploreId={'left'} />);
await openModal(); await openModal();
@ -165,7 +165,7 @@ describe('AddToDashboardButton', () => {
describe('Save to existing dashboard', () => { describe('Save to existing dashboard', () => {
it('Renders the dashboard picker when switching to "Existing Dashboard"', async () => { it('Renders the dashboard picker when switching to "Existing Dashboard"', async () => {
setup(<AddToDashboard exploreId={ExploreId.left} />); setup(<AddToDashboard exploreId={'left'} />);
await openModal(); await openModal();
@ -178,7 +178,7 @@ describe('AddToDashboardButton', () => {
it('Does not submit if no dashboard is selected', async () => { it('Does not submit if no dashboard is selected', async () => {
locationService.push = jest.fn(); locationService.push = jest.fn();
setup(<AddToDashboard exploreId={ExploreId.left} />); setup(<AddToDashboard exploreId={'left'} />);
await openModal(); await openModal();
@ -211,7 +211,7 @@ describe('AddToDashboardButton', () => {
}, },
]); ]);
setup(<AddToDashboard exploreId={ExploreId.left} />); setup(<AddToDashboard exploreId={'left'} />);
await openModal(); await openModal();
@ -252,7 +252,7 @@ describe('AddToDashboardButton', () => {
}, },
]); ]);
setup(<AddToDashboard exploreId={ExploreId.left} />); setup(<AddToDashboard exploreId={'left'} />);
await openModal(); await openModal();
@ -290,7 +290,7 @@ describe('AddToDashboardButton', () => {
return true; return true;
} }
}); });
setup(<AddToDashboard exploreId={ExploreId.left} />); setup(<AddToDashboard exploreId={'left'} />);
await openModal('Add panel to existing dashboard'); await openModal('Add panel to existing dashboard');
expect(screen.queryByRole('radio')).not.toBeInTheDocument(); expect(screen.queryByRole('radio')).not.toBeInTheDocument();
}); });
@ -303,7 +303,7 @@ describe('AddToDashboardButton', () => {
return true; return true;
} }
}); });
setup(<AddToDashboard exploreId={ExploreId.left} />); setup(<AddToDashboard exploreId={'left'} />);
await openModal('Add panel to new dashboard'); await openModal('Add panel to new dashboard');
expect(screen.queryByRole('radio')).not.toBeInTheDocument(); expect(screen.queryByRole('radio')).not.toBeInTheDocument();
}); });
@ -322,7 +322,7 @@ describe('AddToDashboardButton', () => {
jest.spyOn(global, 'open').mockReturnValue(null); jest.spyOn(global, 'open').mockReturnValue(null);
const removeDashboardSpy = jest.spyOn(initDashboard, 'removeDashboardToFetchFromLocalStorage'); const removeDashboardSpy = jest.spyOn(initDashboard, 'removeDashboardToFetchFromLocalStorage');
setup(<AddToDashboard exploreId={ExploreId.left} />); setup(<AddToDashboard exploreId={'left'} />);
await openModal(); await openModal();
expect(screen.queryByRole('alert')).not.toBeInTheDocument(); expect(screen.queryByRole('alert')).not.toBeInTheDocument();
@ -341,7 +341,7 @@ describe('AddToDashboardButton', () => {
throw 'SOME ERROR'; throw 'SOME ERROR';
}); });
setup(<AddToDashboard exploreId={ExploreId.left} />); setup(<AddToDashboard exploreId={'left'} />);
await openModal(); await openModal();
expect(screen.queryByRole('alert')).not.toBeInTheDocument(); expect(screen.queryByRole('alert')).not.toBeInTheDocument();
@ -367,7 +367,7 @@ describe('AddToDashboardButton', () => {
}, },
]); ]);
setup(<AddToDashboard exploreId={ExploreId.left} />); setup(<AddToDashboard exploreId={'left'} />);
await openModal(); await openModal();
expect(screen.queryByRole('alert')).not.toBeInTheDocument(); expect(screen.queryByRole('alert')).not.toBeInTheDocument();
@ -391,7 +391,7 @@ describe('AddToDashboardButton', () => {
it('Shows an error if an unknown error happens', async () => { it('Shows an error if an unknown error happens', async () => {
jest.spyOn(api, 'setDashboardInLocalStorage').mockRejectedValue('SOME ERROR'); jest.spyOn(api, 'setDashboardInLocalStorage').mockRejectedValue('SOME ERROR');
setup(<AddToDashboard exploreId={ExploreId.left} />); setup(<AddToDashboard exploreId={'left'} />);
await openModal(); await openModal();
expect(screen.queryByRole('alert')).not.toBeInTheDocument(); expect(screen.queryByRole('alert')).not.toBeInTheDocument();

View File

@ -1,14 +1,14 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { ToolbarButton } from '@grafana/ui'; import { ToolbarButton } from '@grafana/ui';
import { ExploreId, useSelector } from 'app/types'; import { useSelector } from 'app/types';
import { getExploreItemSelector } from '../state/selectors'; import { getExploreItemSelector } from '../state/selectors';
import { AddToDashboardModal } from './AddToDashboardModal'; import { AddToDashboardModal } from './AddToDashboardModal';
interface Props { interface Props {
exploreId: ExploreId; exploreId: string;
} }
export const AddToDashboard = ({ exploreId }: Props) => { export const AddToDashboard = ({ exploreId }: Props) => {

View File

@ -6,7 +6,6 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { DataSourceApi, LoadingState, CoreApp, createTheme, EventBusSrv } from '@grafana/data'; import { DataSourceApi, LoadingState, CoreApp, createTheme, EventBusSrv } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
import { ExploreId } from 'app/types';
import { Explore, Props } from './Explore'; import { Explore, Props } from './Explore';
import { initialExploreState } from './state/main'; import { initialExploreState } from './state/main';
@ -61,7 +60,7 @@ const dummyProps: Props = {
QueryEditorHelp: {}, QueryEditorHelp: {},
}, },
} as DataSourceApi, } as DataSourceApi,
exploreId: ExploreId.left, exploreId: 'left',
loading: false, loading: false,
modifyQueries: jest.fn(), modifyQueries: jest.fn(),
scanStart: jest.fn(), scanStart: jest.fn(),

View File

@ -34,7 +34,6 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou
import { getNodeGraphDataFrames } from 'app/plugins/panel/nodeGraph/utils'; import { getNodeGraphDataFrames } from 'app/plugins/panel/nodeGraph/utils';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { AbsoluteTimeEvent } from 'app/types/events'; import { AbsoluteTimeEvent } from 'app/types/events';
import { ExploreId } from 'app/types/explore';
import { getTimeZone } from '../profile/state/selectors'; import { getTimeZone } from '../profile/state/selectors';
@ -98,7 +97,7 @@ const getStyles = (theme: GrafanaTheme2) => {
}; };
export interface ExploreProps extends Themeable2 { export interface ExploreProps extends Themeable2 {
exploreId: ExploreId; exploreId: string;
theme: GrafanaTheme2; theme: GrafanaTheme2;
eventBus: EventBus; eventBus: EventBus;
} }

View File

@ -1,24 +1,22 @@
import { useRegisterActions, useKBar, Action, Priority } from 'kbar'; import { useRegisterActions, useKBar, Action, Priority } from 'kbar';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ExploreId, useDispatch, useSelector } from 'app/types'; import { useDispatch, useSelector } from 'app/types';
import { splitOpen, splitClose } from './state/main'; import { splitOpen, splitClose } from './state/main';
import { runQueries } from './state/query'; import { runQueries } from './state/query';
import { isSplit } from './state/selectors'; import { isSplit, selectPanes } from './state/selectors';
interface Props { // FIXME: this should use the new IDs
exploreIdLeft: ExploreId; export const ExploreActions = () => {
exploreIdRight?: ExploreId;
}
export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => {
const [actions, setActions] = useState<Action[]>([]); const [actions, setActions] = useState<Action[]>([]);
const { query } = useKBar(); const { query } = useKBar();
const dispatch = useDispatch(); const dispatch = useDispatch();
const panes = useSelector(selectPanes);
const splitted = useSelector(isSplit); const splitted = useSelector(isSplit);
useEffect(() => { useEffect(() => {
const keys = Object.keys(panes);
const exploreSection = { const exploreSection = {
name: 'Explore', name: 'Explore',
priority: Priority.HIGH + 1, priority: Priority.HIGH + 1,
@ -32,18 +30,18 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => {
name: 'Run query (left)', name: 'Run query (left)',
keywords: 'query left', keywords: 'query left',
perform: () => { perform: () => {
dispatch(runQueries({ exploreId: exploreIdLeft })); dispatch(runQueries({ exploreId: keys[0] }));
}, },
section: exploreSection, section: exploreSection,
}); });
if (exploreIdRight) { if ([panes[1]]) {
// we should always have the right exploreId if split // we should always have the right exploreId if split
actionsArr.push({ actionsArr.push({
id: 'explore/run-query-right', id: 'explore/run-query-right',
name: 'Run query (right)', name: 'Run query (right)',
keywords: 'query right', keywords: 'query right',
perform: () => { perform: () => {
dispatch(runQueries({ exploreId: exploreIdRight })); dispatch(runQueries({ exploreId: keys[1] }));
}, },
section: exploreSection, section: exploreSection,
}); });
@ -52,7 +50,7 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => {
name: 'Close split view left', name: 'Close split view left',
keywords: 'split', keywords: 'split',
perform: () => { perform: () => {
dispatch(splitClose(exploreIdLeft)); dispatch(splitClose(keys[0]));
}, },
section: exploreSection, section: exploreSection,
}); });
@ -61,7 +59,7 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => {
name: 'Close split view right', name: 'Close split view right',
keywords: 'split', keywords: 'split',
perform: () => { perform: () => {
dispatch(splitClose(exploreIdRight)); dispatch(splitClose(keys[1]));
}, },
section: exploreSection, section: exploreSection,
}); });
@ -72,7 +70,7 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => {
name: 'Run query', name: 'Run query',
keywords: 'query', keywords: 'query',
perform: () => { perform: () => {
dispatch(runQueries({ exploreId: exploreIdLeft })); dispatch(runQueries({ exploreId: keys[0] }));
}, },
section: exploreSection, section: exploreSection,
}); });
@ -87,7 +85,7 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => {
}); });
} }
setActions(actionsArr); setActions(actionsArr);
}, [exploreIdLeft, exploreIdRight, splitted, query, dispatch]); }, [panes, splitted, query, dispatch]);
useRegisterActions(!query ? [] : actions, [actions, query]); useRegisterActions(!query ? [] : actions, [actions, query]);

View File

@ -4,8 +4,6 @@ import React, { ComponentProps } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { serializeStateToUrlParam } from '@grafana/data'; import { serializeStateToUrlParam } from '@grafana/data';
import { config } from '@grafana/runtime';
import { ExploreId } from 'app/types';
import { makeLogsQueryResponse } from './spec/helper/query'; import { makeLogsQueryResponse } from './spec/helper/query';
import { setupExplore, tearDown, waitForExplore } from './spec/helper/setup'; import { setupExplore, tearDown, waitForExplore } from './spec/helper/setup';
@ -101,7 +99,7 @@ describe('ExplorePage', () => {
orgId: '1', orgId: '1',
}; };
const { datasources, location } = setupExplore({ urlParams }); const { datasources } = setupExplore({ urlParams });
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse()); jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
jest.mocked(datasources.elastic.query).mockReturnValueOnce(makeLogsQueryResponse()); jest.mocked(datasources.elastic.query).mockReturnValueOnce(makeLogsQueryResponse());
@ -119,9 +117,6 @@ describe('ExplorePage', () => {
expect(screen.getByText(`loki Editor input: { label="value"}`)).toBeInTheDocument(); expect(screen.getByText(`loki Editor input: { label="value"}`)).toBeInTheDocument();
expect(screen.getByText(`elastic Editor input: error`)).toBeInTheDocument(); expect(screen.getByText(`elastic Editor input: error`)).toBeInTheDocument();
// We did not change the url
expect(location.getSearchObject()).toEqual(expect.objectContaining(urlParams));
// We called the data source query method once // We called the data source query method once
expect(datasources.loki.query).toBeCalledTimes(1); expect(datasources.loki.query).toBeCalledTimes(1);
expect(jest.mocked(datasources.loki.query).mock.calls[0][0]).toMatchObject({ expect(jest.mocked(datasources.loki.query).mock.calls[0][0]).toMatchObject({
@ -155,31 +150,6 @@ describe('ExplorePage', () => {
}); });
}); });
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, 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(() => {
location.partial({
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
right: JSON.stringify(['now-1h', 'now', 'elastic', { expr: '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 () => { it('handles opening split with split open func', async () => {
const urlParams = { const urlParams = {
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
@ -207,7 +177,7 @@ describe('ExplorePage', () => {
const splitButton = await screen.findByText(/split/i); const splitButton = await screen.findByText(/split/i);
await userEvent.click(splitButton); await userEvent.click(splitButton);
await waitForExplore(ExploreId.left); await waitForExplore('left');
expect(await screen.findAllByLabelText('Widen pane')).toHaveLength(2); expect(await screen.findAllByLabelText('Widen pane')).toHaveLength(2);
expect(screen.queryByLabelText('Narrow pane')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Narrow pane')).not.toBeInTheDocument();
@ -226,276 +196,4 @@ describe('ExplorePage', () => {
expect(await screen.findAllByLabelText('Narrow pane')).toHaveLength(1); expect(await screen.findAllByLabelText('Narrow pane')).toHaveLength(1);
}); });
}); });
describe('Handles document title changes', () => {
it('changes the document title of the explore page to include the datasource in use', async () => {
const urlParams = {
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
};
const { datasources } = setupExplore({ urlParams });
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
// This is mainly to wait for render so that the left pane state is initialized as that is needed for the title
// to include the datasource
await screen.findByText(`loki Editor input: { label="value"}`);
await waitFor(() => expect(document.title).toEqual('Explore - loki - Grafana'));
});
it('changes the document title to include the two datasources in use in split view mode', async () => {
const urlParams = {
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
};
const { datasources, store } = setupExplore({ urlParams });
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"}`);
act(() => {
store.dispatch(mainState.splitOpen({ datasourceUid: 'elastic', query: { expr: 'error', refId: 'A' } }));
});
await waitFor(() => expect(document.title).toEqual('Explore - loki | elastic - Grafana'));
});
});
describe('Handles different URL datasource redirects', () => {
describe('exploreMixedDatasource on', () => {
beforeAll(() => {
config.featureToggles.exploreMixedDatasource = true;
});
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);
});
});
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","datasource":{"type":"logs","uid":"elastic-uid"}}],"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'
);
});
});
});
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'
);
});
});
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'
);
});
});
});
});
describe('exploreMixedDatasource off', () => {
beforeAll(() => {
config.featureToggles.exploreMixedDatasource = false;
});
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,
});
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('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();
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('removes `from` and `to` parameters from url when first mounted', async () => {
const { location } = setupExplore({ urlParams: { from: '1', to: '2' } });
await waitForExplore();
await waitFor(() => {
expect(location.getSearchObject()).toEqual(expect.not.objectContaining({ from: '1', to: '2' }));
expect(location.getSearchObject()).toEqual(expect.objectContaining({ orgId: '1' }));
});
});
}); });

View File

@ -9,17 +9,16 @@ import { useGrafana } from 'app/core/context/GrafanaContext';
import { useNavModel } from 'app/core/hooks/useNavModel'; import { useNavModel } from 'app/core/hooks/useNavModel';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { useDispatch, useSelector } from 'app/types'; import { useDispatch, useSelector } from 'app/types';
import { ExploreId, ExploreQueryParams } from 'app/types/explore'; import { ExploreQueryParams } from 'app/types/explore';
import { ExploreActions } from './ExploreActions'; import { ExploreActions } from './ExploreActions';
import { ExplorePaneContainer } from './ExplorePaneContainer'; import { ExplorePaneContainer } from './ExplorePaneContainer';
import { useExploreCorrelations } from './hooks/useExploreCorrelations'; import { useExploreCorrelations } from './hooks/useExploreCorrelations';
import { useExplorePageTitle } from './hooks/useExplorePageTitle'; import { useExplorePageTitle } from './hooks/useExplorePageTitle';
import { useStateSync } from './hooks/useStateSync'; import { useStateSync } from './hooks/useStateSync';
import { useStopQueries } from './hooks/useStopQueries';
import { useTimeSrvFix } from './hooks/useTimeSrvFix'; import { useTimeSrvFix } from './hooks/useTimeSrvFix';
import { splitSizeUpdateAction } from './state/main'; import { splitSizeUpdateAction } from './state/main';
import { selectOrderedExplorePanes } from './state/selectors'; import { isSplit, selectPanesEntries } from './state/selectors';
const styles = { const styles = {
pageScrollbarWrapper: css` pageScrollbarWrapper: css`
@ -32,10 +31,14 @@ const styles = {
}; };
export default function ExplorePage(props: GrafanaRouteComponentProps<{}, ExploreQueryParams>) { export default function ExplorePage(props: GrafanaRouteComponentProps<{}, ExploreQueryParams>) {
useStopQueries();
useTimeSrvFix(); useTimeSrvFix();
useStateSync(props.queryParams); useStateSync(props.queryParams);
useExplorePageTitle(); // We want to set the title according to the URL and not to the state because the URL itself may lag
// (due to how useStateSync above works) by a few milliseconds.
// When a URL is pushed to the history, the browser also saves the title of the page and
// if we were to update the URL on state change, the title would not match the URL.
// Ultimately the URL is the single source of truth from which state is derived, the page title is not different
useExplorePageTitle(props.queryParams);
useExploreCorrelations(); useExploreCorrelations();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { keybindings, chrome } = useGrafana(); const { keybindings, chrome } = useGrafana();
@ -45,7 +48,8 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
const minWidth = 200; const minWidth = 200;
const exploreState = useSelector((state) => state.explore); const exploreState = useSelector((state) => state.explore);
const panes = useSelector(selectOrderedExplorePanes); const panes = useSelector(selectPanesEntries);
const hasSplit = useSelector(isSplit);
useEffect(() => { useEffect(() => {
//This is needed for breadcrumbs and topnav. //This is needed for breadcrumbs and topnav.
@ -65,7 +69,7 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
} else { } else {
dispatch( dispatch(
splitSizeUpdateAction({ splitSizeUpdateAction({
largerExploreId: size > evenSplitWidth ? ExploreId.right : ExploreId.left, largerExploreId: size > evenSplitWidth ? panes[1][0] : panes[0][0],
}) })
); );
} }
@ -73,11 +77,10 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
setRightPaneWidthRatio(size / windowWidth); setRightPaneWidthRatio(size / windowWidth);
}; };
const hasSplit = Object.entries(panes).length > 1;
let widthCalc = 0; let widthCalc = 0;
if (hasSplit) { if (hasSplit) {
if (!exploreState.evenSplitPanes && exploreState.maxedExploreId) { if (!exploreState.evenSplitPanes && exploreState.maxedExploreId) {
widthCalc = exploreState.maxedExploreId === ExploreId.right ? windowWidth - minWidth : minWidth; widthCalc = exploreState.maxedExploreId === panes[1][0] ? windowWidth - minWidth : minWidth;
} else if (exploreState.evenSplitPanes) { } else if (exploreState.evenSplitPanes) {
widthCalc = Math.floor(windowWidth / 2); widthCalc = Math.floor(windowWidth / 2);
} else if (rightPaneWidthRatio !== undefined) { } else if (rightPaneWidthRatio !== undefined) {
@ -87,7 +90,7 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
return ( return (
<div className={styles.pageScrollbarWrapper}> <div className={styles.pageScrollbarWrapper}>
<ExploreActions exploreIdLeft={ExploreId.left} exploreIdRight={ExploreId.right} /> <ExploreActions />
<SplitPaneWrapper <SplitPaneWrapper
splitOrientation="vertical" splitOrientation="vertical"
@ -97,16 +100,12 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
primary="second" primary="second"
splitVisible={hasSplit} splitVisible={hasSplit}
paneStyle={{ overflow: 'auto', display: 'flex', flexDirection: 'column' }} paneStyle={{ overflow: 'auto', display: 'flex', flexDirection: 'column' }}
onDragFinished={(size) => { onDragFinished={(size) => size && updateSplitSize(size)}
if (size) {
updateSplitSize(size);
}
}}
> >
{Object.keys(panes).map((exploreId) => { {panes.map(([exploreId]) => {
return ( return (
<ErrorBoundaryAlert key={exploreId} style="page"> <ErrorBoundaryAlert key={exploreId} style="page">
<ExplorePaneContainer exploreId={exploreId as ExploreId} /> <ExplorePaneContainer exploreId={exploreId} />
</ErrorBoundaryAlert> </ErrorBoundaryAlert>
); );
})} })}

View File

@ -1,14 +1,15 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { EventBusSrv, GrafanaTheme2 } from '@grafana/data'; import { EventBusSrv, GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { useStyles2 } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui';
import { StoreState } from 'app/types'; import { stopQueryState } from 'app/core/utils/explore';
import { ExploreId } from 'app/types/explore'; import { StoreState, useSelector } from 'app/types';
import Explore from './Explore'; import Explore from './Explore';
import { getExploreItemSelector } from './state/selectors';
const getStyles = (theme: GrafanaTheme2) => { const getStyles = (theme: GrafanaTheme2) => {
return { return {
@ -26,7 +27,7 @@ const getStyles = (theme: GrafanaTheme2) => {
}; };
interface Props { interface Props {
exploreId: ExploreId; exploreId: string;
} }
/* /*
@ -39,6 +40,7 @@ interface Props {
You can read more about this issue here: https://react-redux.js.org/api/hooks#stale-props-and-zombie-children You can read more about this issue here: https://react-redux.js.org/api/hooks#stale-props-and-zombie-children
*/ */
function ExplorePaneContainerUnconnected({ exploreId }: Props) { function ExplorePaneContainerUnconnected({ exploreId }: Props) {
useStopQueries(exploreId);
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const eventBus = useRef(new EventBusSrv()); const eventBus = useRef(new EventBusSrv());
const ref = useRef(null); const ref = useRef(null);
@ -64,3 +66,15 @@ function mapStateToProps(state: StoreState, props: Props) {
const connector = connect(mapStateToProps); const connector = connect(mapStateToProps);
export const ExplorePaneContainer = connector(ExplorePaneContainerUnconnected); export const ExplorePaneContainer = connector(ExplorePaneContainerUnconnected);
function useStopQueries(exploreId: string) {
const paneSelector = useMemo(() => getExploreItemSelector(exploreId), [exploreId]);
const paneRef = useRef<ReturnType<typeof paneSelector>>();
paneRef.current = useSelector(paneSelector);
useEffect(() => {
return () => {
stopQueryState(paneRef.current?.querySubscription);
};
}, []);
}

View File

@ -3,7 +3,6 @@ import React, { ComponentProps } from 'react';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { LoadingState, InternalTimeZones, getDefaultTimeRange } from '@grafana/data'; import { LoadingState, InternalTimeZones, getDefaultTimeRange } from '@grafana/data';
import { ExploreId } from 'app/types';
import { ExploreQueryInspector } from './ExploreQueryInspector'; import { ExploreQueryInspector } from './ExploreQueryInspector';
@ -38,7 +37,7 @@ const setup = (propOverrides = {}) => {
const props: ExploreQueryInspectorProps = { const props: ExploreQueryInspectorProps = {
loading: false, loading: false,
width: 100, width: 100,
exploreId: ExploreId.left, exploreId: 'left',
onClose: jest.fn(), onClose: jest.fn(),
timeZone: InternalTimeZones.utc, timeZone: InternalTimeZones.utc,
queryResponse: { queryResponse: {

View File

@ -10,13 +10,13 @@ import { InspectErrorTab } from 'app/features/inspector/InspectErrorTab';
import { InspectJSONTab } from 'app/features/inspector/InspectJSONTab'; import { InspectJSONTab } from 'app/features/inspector/InspectJSONTab';
import { InspectStatsTab } from 'app/features/inspector/InspectStatsTab'; import { InspectStatsTab } from 'app/features/inspector/InspectStatsTab';
import { QueryInspector } from 'app/features/inspector/QueryInspector'; import { QueryInspector } from 'app/features/inspector/QueryInspector';
import { StoreState, ExploreItemState, ExploreId } from 'app/types'; import { StoreState, ExploreItemState } from 'app/types';
import { runQueries, selectIsWaitingForData } from './state/query'; import { runQueries, selectIsWaitingForData } from './state/query';
interface DispatchProps { interface DispatchProps {
width: number; width: number;
exploreId: ExploreId; exploreId: string;
timeZone: TimeZone; timeZone: TimeZone;
onClose: () => void; onClose: () => void;
} }
@ -90,7 +90,7 @@ export function ExploreQueryInspector(props: Props) {
); );
} }
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }) {
const explore = state.explore; const explore = state.explore;
const item: ExploreItemState = explore.panes[exploreId]!; const item: ExploreItemState = explore.panes[exploreId]!;
const { queryResponse } = item; const { queryResponse } = item;

View File

@ -4,12 +4,11 @@ import { TimeRange, TimeZone, RawTimeRange, dateTimeForTimeZone, dateMath } from
import { reportInteraction } from '@grafana/runtime'; import { reportInteraction } from '@grafana/runtime';
import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePickerWithHistory'; import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePickerWithHistory';
import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker';
import { ExploreId } from 'app/types';
import { TimeSyncButton } from './TimeSyncButton'; import { TimeSyncButton } from './TimeSyncButton';
export interface Props { export interface Props {
exploreId: ExploreId; exploreId: string;
hideText?: boolean; hideText?: boolean;
range: TimeRange; range: TimeRange;
timeZone: TimeZone; timeZone: TimeZone;

View File

@ -11,7 +11,6 @@ import { contextSrv } from 'app/core/core';
import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { StoreState, useDispatch, useSelector } from 'app/types/store'; import { StoreState, useDispatch, useSelector } from 'app/types/store';
import { DashNavButton } from '../dashboard/components/DashNav/DashNavButton'; import { DashNavButton } from '../dashboard/components/DashNav/DashNavButton';
@ -24,7 +23,7 @@ import { LiveTailButton } from './LiveTailButton';
import { changeDatasource } from './state/datasource'; import { changeDatasource } from './state/datasource';
import { splitClose, splitOpen, maximizePaneAction, evenPaneResizeAction } from './state/main'; import { splitClose, splitOpen, maximizePaneAction, evenPaneResizeAction } from './state/main';
import { cancelQueries, runQueries, selectIsWaitingForData } from './state/query'; import { cancelQueries, runQueries, selectIsWaitingForData } from './state/query';
import { isSplit } from './state/selectors'; import { isSplit, selectPanesEntries } from './state/selectors';
import { syncTimes, changeRefreshInterval } from './state/time'; import { syncTimes, changeRefreshInterval } from './state/time';
import { LiveTailControls } from './useLiveTailControls'; import { LiveTailControls } from './useLiveTailControls';
@ -39,7 +38,7 @@ const rotateIcon = css({
}); });
interface Props { interface Props {
exploreId: ExploreId; exploreId: string;
onChangeTime: (range: RawTimeRange, changedByScanner?: boolean) => void; onChangeTime: (range: RawTimeRange, changedByScanner?: boolean) => void;
topOfViewRef: RefObject<HTMLDivElement>; topOfViewRef: RefObject<HTMLDivElement>;
} }
@ -64,9 +63,11 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props)
(state) => state.explore.panes[exploreId]!.containerWidth < (splitted ? 700 : 800) (state) => state.explore.panes[exploreId]!.containerWidth < (splitted ? 700 : 800)
); );
const panes = useSelector(selectPanesEntries);
const shouldRotateSplitIcon = useMemo( const shouldRotateSplitIcon = useMemo(
() => (exploreId === 'left' && isLargerPane) || (exploreId === 'right' && !isLargerPane), () => (exploreId === panes[0][0] && isLargerPane) || (exploreId === panes[1]?.[0] && !isLargerPane),
[isLargerPane, exploreId] [isLargerPane, exploreId, panes]
); );
const onCopyShortLink = () => { const onCopyShortLink = () => {

View File

@ -11,7 +11,6 @@ import {
MutableDataFrame, MutableDataFrame,
toUtc, toUtc,
} from '@grafana/data'; } from '@grafana/data';
import { ExploreId } from 'app/types';
import { Logs } from './Logs'; import { Logs } from './Logs';
@ -46,7 +45,7 @@ jest.mock('app/store/store', () => ({
const changePanelState = jest.fn(); const changePanelState = jest.fn();
jest.mock('../state/explorePane', () => ({ jest.mock('../state/explorePane', () => ({
...jest.requireActual('../state/explorePane'), ...jest.requireActual('../state/explorePane'),
changePanelState: (exploreId: ExploreId, panel: 'logs', panelState: {} | ExploreLogsPanelState) => { changePanelState: (exploreId: string, panel: 'logs', panelState: {} | ExploreLogsPanelState) => {
return changePanelState(exploreId, panel, panelState); return changePanelState(exploreId, panel, panelState);
}, },
})); }));
@ -85,7 +84,7 @@ describe('Logs', () => {
return ( return (
<Logs <Logs
exploreId={ExploreId.left} exploreId={'left'}
splitOpen={() => undefined} splitOpen={() => undefined}
logsVolumeEnabled={true} logsVolumeEnabled={true}
onSetLogsVolumeEnabled={() => null} onSetLogsVolumeEnabled={() => null}
@ -141,7 +140,7 @@ describe('Logs', () => {
const scanningStarted = jest.fn(); const scanningStarted = jest.fn();
render( render(
<Logs <Logs
exploreId={ExploreId.left} exploreId={'left'}
splitOpen={() => undefined} splitOpen={() => undefined}
logsVolumeEnabled={true} logsVolumeEnabled={true}
onSetLogsVolumeEnabled={() => null} onSetLogsVolumeEnabled={() => null}
@ -178,7 +177,7 @@ describe('Logs', () => {
it('should render a stop scanning button', () => { it('should render a stop scanning button', () => {
render( render(
<Logs <Logs
exploreId={ExploreId.left} exploreId={'left'}
splitOpen={() => undefined} splitOpen={() => undefined}
logsVolumeEnabled={true} logsVolumeEnabled={true}
onSetLogsVolumeEnabled={() => null} onSetLogsVolumeEnabled={() => null}
@ -218,7 +217,7 @@ describe('Logs', () => {
render( render(
<Logs <Logs
exploreId={ExploreId.left} exploreId={'left'}
splitOpen={() => undefined} splitOpen={() => undefined}
logsVolumeEnabled={true} logsVolumeEnabled={true}
onSetLogsVolumeEnabled={() => null} onSetLogsVolumeEnabled={() => null}
@ -270,10 +269,10 @@ describe('Logs', () => {
const panelState = { logs: { id: '1' } }; const panelState = { logs: { id: '1' } };
const { rerender } = setup({ loading: false, panelState }); const { rerender } = setup({ loading: false, panelState });
rerender(getComponent({ loading: true, exploreId: ExploreId.right, panelState })); rerender(getComponent({ loading: true, exploreId: 'right', panelState }));
rerender(getComponent({ loading: false, exploreId: ExploreId.right, panelState })); rerender(getComponent({ loading: false, exploreId: 'right', panelState }));
expect(changePanelState).toHaveBeenCalledWith(ExploreId.right, 'logs', { logs: {} }); expect(changePanelState).toHaveBeenCalledWith('right', 'logs', { logs: {} });
}); });
it('should scroll the scrollElement into view if rows contain id', () => { it('should scroll the scrollElement into view if rows contain id', () => {

View File

@ -46,7 +46,6 @@ import { dedupLogRows, filterLogLevels } from 'app/core/logsModel';
import store from 'app/core/store'; import store from 'app/core/store';
import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
import { getState, dispatch } from 'app/store/store'; import { getState, dispatch } from 'app/store/store';
import { ExploreId } from 'app/types/explore';
import { LogRows } from '../../logs/components/LogRows'; import { LogRows } from '../../logs/components/LogRows';
import { LogRowContextModal } from '../../logs/components/log-context/LogRowContextModal'; import { LogRowContextModal } from '../../logs/components/log-context/LogRowContextModal';
@ -73,7 +72,7 @@ interface Props extends Themeable2 {
timeZone: TimeZone; timeZone: TimeZone;
scanning?: boolean; scanning?: boolean;
scanRange?: RawTimeRange; scanRange?: RawTimeRange;
exploreId: ExploreId; exploreId: string;
datasourceType?: string; datasourceType?: string;
logsVolumeEnabled: boolean; logsVolumeEnabled: boolean;
logsVolumeData: DataQueryResponse | undefined; logsVolumeData: DataQueryResponse | undefined;

View File

@ -21,7 +21,7 @@ import {
import { DataQuery } from '@grafana/schema'; import { DataQuery } from '@grafana/schema';
import { Collapse } from '@grafana/ui'; import { Collapse } from '@grafana/ui';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { ExploreId, ExploreItemState } from 'app/types/explore'; import { ExploreItemState } from 'app/types/explore';
import { getTimeZone } from '../../profile/state/selectors'; import { getTimeZone } from '../../profile/state/selectors';
import { import {
@ -41,7 +41,7 @@ import { LogsCrossFadeTransition } from './utils/LogsCrossFadeTransition';
interface LogsContainerProps extends PropsFromRedux { interface LogsContainerProps extends PropsFromRedux {
width: number; width: number;
exploreId: ExploreId; exploreId: string;
scanRange?: RawTimeRange; scanRange?: RawTimeRange;
syncedTimes: boolean; syncedTimes: boolean;
loadingState: LoadingState; loadingState: LoadingState;
@ -218,7 +218,7 @@ class LogsContainer extends PureComponent<LogsContainerProps> {
} }
} }
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }) {
const explore = state.explore; const explore = state.explore;
const item: ExploreItemState = explore.panes[exploreId]!; const item: ExploreItemState = explore.panes[exploreId]!;
const { const {

View File

@ -2,7 +2,6 @@ import { render, screen } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { getDefaultTimeRange, MutableDataFrame } from '@grafana/data'; import { getDefaultTimeRange, MutableDataFrame } from '@grafana/data';
import { ExploreId } from 'app/types';
import { UnconnectedNodeGraphContainer } from './NodeGraphContainer'; import { UnconnectedNodeGraphContainer } from './NodeGraphContainer';
@ -11,7 +10,7 @@ describe('NodeGraphContainer', () => {
const { container } = render( const { container } = render(
<UnconnectedNodeGraphContainer <UnconnectedNodeGraphContainer
dataFrames={[emptyFrame]} dataFrames={[emptyFrame]}
exploreId={ExploreId.left} exploreId={'left'}
range={getDefaultTimeRange()} range={getDefaultTimeRange()}
splitOpenFn={() => {}} splitOpenFn={() => {}}
withTraceView={true} withTraceView={true}
@ -27,7 +26,7 @@ describe('NodeGraphContainer', () => {
const { container } = render( const { container } = render(
<UnconnectedNodeGraphContainer <UnconnectedNodeGraphContainer
dataFrames={[nodes]} dataFrames={[nodes]}
exploreId={ExploreId.left} exploreId={'left'}
range={getDefaultTimeRange()} range={getDefaultTimeRange()}
splitOpenFn={() => {}} splitOpenFn={() => {}}
datasourceType={''} datasourceType={''}

View File

@ -9,7 +9,7 @@ import { Collapse, useStyles2, useTheme2 } from '@grafana/ui';
import { NodeGraph } from '../../../plugins/panel/nodeGraph'; import { NodeGraph } from '../../../plugins/panel/nodeGraph';
import { useCategorizeFrames } from '../../../plugins/panel/nodeGraph/useCategorizeFrames'; import { useCategorizeFrames } from '../../../plugins/panel/nodeGraph/useCategorizeFrames';
import { ExploreId, StoreState } from '../../../types'; import { StoreState } from '../../../types';
import { useLinks } from '../utils/links'; import { useLinks } from '../utils/links';
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
@ -23,7 +23,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
interface OwnProps { interface OwnProps {
// Edges and Nodes are separate frames // Edges and Nodes are separate frames
dataFrames: DataFrame[]; dataFrames: DataFrame[];
exploreId: ExploreId; exploreId: string;
// When showing the node graph together with trace view we do some changes so it works better. // When showing the node graph together with trace view we do some changes so it works better.
withTraceView?: boolean; withTraceView?: boolean;
datasourceType: string; datasourceType: string;

View File

@ -5,7 +5,7 @@ import { Provider } from 'react-redux';
import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema'; import { DataQuery } from '@grafana/schema';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
import { ExploreId, ExploreState } from 'app/types'; import { ExploreState } from 'app/types';
import { UserState } from '../profile/state/reducers'; import { UserState } from '../profile/state/reducers';
@ -77,7 +77,7 @@ describe('Explore QueryRows', () => {
render( render(
<Provider store={store}> <Provider store={store}>
<QueryRows exploreId={ExploreId.left} /> <QueryRows exploreId={'left'} />
</Provider> </Provider>
); );

View File

@ -6,7 +6,6 @@ import { reportInteraction } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema'; import { DataQuery } from '@grafana/schema';
import { getNextRefIdChar } from 'app/core/utils/query'; import { getNextRefIdChar } from 'app/core/utils/query';
import { useDispatch, useSelector } from 'app/types'; import { useDispatch, useSelector } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { getDatasourceSrv } from '../plugins/datasource_srv'; import { getDatasourceSrv } from '../plugins/datasource_srv';
import { QueryEditorRows } from '../query/components/QueryEditorRows'; import { QueryEditorRows } from '../query/components/QueryEditorRows';
@ -15,10 +14,10 @@ import { changeQueries, runQueries } from './state/query';
import { getExploreItemSelector } from './state/selectors'; import { getExploreItemSelector } from './state/selectors';
interface Props { interface Props {
exploreId: ExploreId; exploreId: string;
} }
const makeSelectors = (exploreId: ExploreId) => { const makeSelectors = (exploreId: string) => {
const exploreItemSelector = getExploreItemSelector(exploreId); const exploreItemSelector = getExploreItemSelector(exploreId);
return { return {
getQueries: createSelector(exploreItemSelector, (s) => s!.queries), getQueries: createSelector(exploreItemSelector, (s) => s!.queries),

View File

@ -2,7 +2,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { FieldType, getDefaultTimeRange, InternalTimeZones, toDataFrame } from '@grafana/data'; import { FieldType, getDefaultTimeRange, InternalTimeZones, toDataFrame } from '@grafana/data';
import { ExploreId, TABLE_RESULTS_STYLE } from 'app/types'; import { TABLE_RESULTS_STYLE } from 'app/types';
import { RawPrometheusContainer } from './RawPrometheusContainer'; import { RawPrometheusContainer } from './RawPrometheusContainer';
@ -52,7 +52,7 @@ const dataFrame = toDataFrame({
}); });
const defaultProps = { const defaultProps = {
exploreId: ExploreId.left, exploreId: 'left',
loading: false, loading: false,
width: 800, width: 800,
onCellFilterAdded: jest.fn(), onCellFilterAdded: jest.fn(),

View File

@ -8,7 +8,7 @@ import { Collapse, RadioButtonGroup, Table, AdHocFilterItem } from '@grafana/ui'
import { config } from 'app/core/config'; import { config } from 'app/core/config';
import { PANEL_BORDER } from 'app/core/constants'; import { PANEL_BORDER } from 'app/core/constants';
import { StoreState, TABLE_RESULTS_STYLE } from 'app/types'; import { StoreState, TABLE_RESULTS_STYLE } from 'app/types';
import { ExploreId, ExploreItemState, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore'; import { ExploreItemState, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore';
import { MetaInfoText } from '../MetaInfoText'; import { MetaInfoText } from '../MetaInfoText';
import RawListContainer from '../PrometheusListView/RawListContainer'; import RawListContainer from '../PrometheusListView/RawListContainer';
@ -17,7 +17,7 @@ import { getFieldLinksForExplore } from '../utils/links';
interface RawPrometheusContainerProps { interface RawPrometheusContainerProps {
ariaLabel?: string; ariaLabel?: string;
exploreId: ExploreId; exploreId: string;
width: number; width: number;
timeZone: TimeZone; timeZone: TimeZone;
onCellFilterAdded?: (filter: AdHocFilterItem) => void; onCellFilterAdded?: (filter: AdHocFilterItem) => void;

View File

@ -4,7 +4,6 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { DataQueryError, LoadingState } from '@grafana/data'; import { DataQueryError, LoadingState } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { ExploreId } from 'app/types';
import { configureStore } from '../../store/configureStore'; import { configureStore } from '../../store/configureStore';
@ -60,7 +59,7 @@ function setup(error: DataQueryError) {
render( render(
<TestProvider store={store}> <TestProvider store={store}>
<ResponseErrorContainer exploreId={ExploreId.left} /> <ResponseErrorContainer exploreId="left" />
</TestProvider> </TestProvider>
); );
} }

View File

@ -3,12 +3,10 @@ import React from 'react';
import { LoadingState } from '@grafana/data'; import { LoadingState } from '@grafana/data';
import { useSelector } from 'app/types'; import { useSelector } from 'app/types';
import { ExploreId } from '../../types';
import { ErrorContainer } from './ErrorContainer'; import { ErrorContainer } from './ErrorContainer';
interface Props { interface Props {
exploreId: ExploreId; exploreId: string;
} }
export function ResponseErrorContainer(props: Props) { export function ResponseErrorContainer(props: Props) {
const queryResponse = useSelector((state) => state.explore.panes[props.exploreId]!.queryResponse); const queryResponse = useSelector((state) => state.explore.panes[props.exploreId]!.queryResponse);

View File

@ -3,7 +3,6 @@ import React from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { SortOrder } from 'app/core/utils/richHistory'; import { SortOrder } from 'app/core/utils/richHistory';
import { ExploreId } from 'app/types';
import { RichHistory, RichHistoryProps, Tabs } from './RichHistory'; import { RichHistory, RichHistoryProps, Tabs } from './RichHistory';
@ -23,7 +22,7 @@ jest.mock('@grafana/runtime', () => ({
const setup = (propOverrides?: Partial<RichHistoryProps>) => { const setup = (propOverrides?: Partial<RichHistoryProps>) => {
const props: RichHistoryProps = { const props: RichHistoryProps = {
theme: {} as GrafanaTheme2, theme: {} as GrafanaTheme2,
exploreId: ExploreId.left, exploreId: 'left',
height: 100, height: 100,
activeDatasourceInstance: 'Test datasource', activeDatasourceInstance: 'Test datasource',
richHistory: [], richHistory: [],

View File

@ -4,7 +4,7 @@ import React, { PureComponent } from 'react';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { Themeable2, TabbedContainer, TabConfig, withTheme2 } from '@grafana/ui'; import { Themeable2, TabbedContainer, TabConfig, withTheme2 } from '@grafana/ui';
import { SortOrder, RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistory'; import { SortOrder, RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistory';
import { RichHistoryQuery, ExploreId } from 'app/types/explore'; import { RichHistoryQuery } from 'app/types/explore';
import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider'; import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider';
@ -32,14 +32,14 @@ export interface RichHistoryProps extends Themeable2 {
richHistorySettings: RichHistorySettings; richHistorySettings: RichHistorySettings;
richHistorySearchFilters?: RichHistorySearchFilters; richHistorySearchFilters?: RichHistorySearchFilters;
updateHistorySettings: (settings: RichHistorySettings) => void; updateHistorySettings: (settings: RichHistorySettings) => void;
updateHistorySearchFilters: (exploreId: ExploreId, filters: RichHistorySearchFilters) => void; updateHistorySearchFilters: (exploreId: string, filters: RichHistorySearchFilters) => void;
loadRichHistory: (exploreId: ExploreId) => void; loadRichHistory: (exploreId: string) => void;
loadMoreRichHistory: (exploreId: ExploreId) => void; loadMoreRichHistory: (exploreId: string) => void;
clearRichHistoryResults: (exploreId: ExploreId) => void; clearRichHistoryResults: (exploreId: string) => void;
deleteRichHistory: () => void; deleteRichHistory: () => void;
activeDatasourceInstance: string; activeDatasourceInstance: string;
firstTab: Tabs; firstTab: Tabs;
exploreId: ExploreId; exploreId: string;
height: number; height: number;
onClose: () => void; onClose: () => void;
} }

View File

@ -6,7 +6,7 @@ import { DataSourceApi, DataSourceInstanceSettings, DataSourcePluginMeta } from
import { DataQuery, DataSourceRef } from '@grafana/schema'; import { DataQuery, DataSourceRef } from '@grafana/schema';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { MixedDatasource } from 'app/plugins/datasource/mixed/MixedDataSource'; import { MixedDatasource } from 'app/plugins/datasource/mixed/MixedDataSource';
import { ExploreId, RichHistoryQuery } from 'app/types'; import { RichHistoryQuery } from 'app/types';
import { ShowConfirmModalEvent } from 'app/types/events'; import { ShowConfirmModalEvent } from 'app/types/events';
import { RichHistoryCard, Props } from './RichHistoryCard'; import { RichHistoryCard, Props } from './RichHistoryCard';
@ -120,7 +120,7 @@ const setup = (propOverrides?: Partial<Props<MockQuery>>) => {
deleteHistoryItem: deleteRichHistoryMock, deleteHistoryItem: deleteRichHistoryMock,
commentHistoryItem: jest.fn(), commentHistoryItem: jest.fn(),
setQueries: jest.fn(), setQueries: jest.fn(),
exploreId: ExploreId.left, exploreId: 'left',
datasourceInstance: dsStore.loki, datasourceInstance: dsStore.loki,
}; };

View File

@ -19,9 +19,9 @@ import { setQueries } from 'app/features/explore/state/query';
import { dispatch } from 'app/store/store'; import { dispatch } from 'app/store/store';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { ShowConfirmModalEvent } from 'app/types/events'; import { ShowConfirmModalEvent } from 'app/types/events';
import { RichHistoryQuery, ExploreId } from 'app/types/explore'; import { RichHistoryQuery } from 'app/types/explore';
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }) {
const explore = state.explore; const explore = state.explore;
const { datasourceInstance } = explore.panes[exploreId]!; const { datasourceInstance } = explore.panes[exploreId]!;
return { return {

View File

@ -2,7 +2,6 @@ import { render } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { SortOrder } from 'app/core/utils/richHistory'; import { SortOrder } from 'app/core/utils/richHistory';
import { ExploreId } from 'app/types';
import { Tabs } from './RichHistory'; import { Tabs } from './RichHistory';
import { RichHistoryContainer, Props } from './RichHistoryContainer'; import { RichHistoryContainer, Props } from './RichHistoryContainer';
@ -22,7 +21,7 @@ jest.mock('@grafana/runtime', () => ({
const setup = (propOverrides?: Partial<Props>) => { const setup = (propOverrides?: Partial<Props>) => {
const props: Props = { const props: Props = {
width: 500, width: 500,
exploreId: ExploreId.left, exploreId: 'left',
activeDatasourceInstance: 'Test datasource', activeDatasourceInstance: 'Test datasource',
richHistory: [], richHistory: [],
firstTab: Tabs.RichHistory, firstTab: Tabs.RichHistory,

View File

@ -6,7 +6,6 @@ import { config, reportInteraction } from '@grafana/runtime';
import { useTheme2 } from '@grafana/ui'; import { useTheme2 } from '@grafana/ui';
// Types // Types
import { ExploreItemState, StoreState } from 'app/types'; import { ExploreItemState, StoreState } from 'app/types';
import { ExploreId } from 'app/types/explore';
// Components, enums // Components, enums
import { ExploreDrawer } from '../ExploreDrawer'; import { ExploreDrawer } from '../ExploreDrawer';
@ -24,7 +23,7 @@ import { RichHistory, Tabs } from './RichHistory';
//Actions //Actions
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }) {
const explore = state.explore; const explore = state.explore;
const item: ExploreItemState = explore.panes[exploreId]!; const item: ExploreItemState = explore.panes[exploreId]!;
const richHistorySearchFilters = item.richHistorySearchFilters; const richHistorySearchFilters = item.richHistorySearchFilters;
@ -56,7 +55,7 @@ const connector = connect(mapStateToProps, mapDispatchToProps);
interface OwnProps { interface OwnProps {
width: number; width: number;
exploreId: ExploreId; exploreId: string;
onClose: () => void; onClose: () => void;
} }
export type Props = ConnectedProps<typeof connector> & OwnProps; export type Props = ConnectedProps<typeof connector> & OwnProps;

View File

@ -3,7 +3,6 @@ import React from 'react';
import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime';
import { SortOrder } from 'app/core/utils/richHistoryTypes'; import { SortOrder } from 'app/core/utils/richHistoryTypes';
import { ExploreId } from 'app/types';
import { RichHistoryQueriesTab, RichHistoryQueriesTabProps } from './RichHistoryQueriesTab'; import { RichHistoryQueriesTab, RichHistoryQueriesTabProps } from './RichHistoryQueriesTab';
@ -30,7 +29,7 @@ const setup = (propOverrides?: Partial<RichHistoryQueriesTabProps>) => {
lastUsedDatasourceFilters: [], lastUsedDatasourceFilters: [],
starredTabAsFirstTab: false, starredTabAsFirstTab: false,
}, },
exploreId: ExploreId.left, exploreId: 'left',
height: 100, height: 100,
}; };

View File

@ -12,7 +12,7 @@ import {
RichHistorySearchFilters, RichHistorySearchFilters,
RichHistorySettings, RichHistorySettings,
} from 'app/core/utils/richHistory'; } from 'app/core/utils/richHistory';
import { ExploreId, RichHistoryQuery } from 'app/types/explore'; import { RichHistoryQuery } from 'app/types/explore';
import { getSortOrderOptions } from './RichHistory'; import { getSortOrderOptions } from './RichHistory';
import RichHistoryCard from './RichHistoryCard'; import RichHistoryCard from './RichHistoryCard';
@ -27,7 +27,7 @@ export interface RichHistoryQueriesTabProps {
loadMoreRichHistory: () => void; loadMoreRichHistory: () => void;
richHistorySettings: RichHistorySettings; richHistorySettings: RichHistorySettings;
richHistorySearchFilters?: RichHistorySearchFilters; richHistorySearchFilters?: RichHistorySearchFilters;
exploreId: ExploreId; exploreId: string;
height: number; height: number;
} }

View File

@ -2,7 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { SortOrder } from 'app/core/utils/richHistory'; import { SortOrder } from 'app/core/utils/richHistory';
import { ExploreId } from 'app/types';
import { RichHistoryStarredTab, RichHistoryStarredTabProps } from './RichHistoryStarredTab'; import { RichHistoryStarredTab, RichHistoryStarredTabProps } from './RichHistoryStarredTab';
@ -26,7 +25,7 @@ const setup = (propOverrides?: Partial<RichHistoryStarredTabProps>) => {
updateFilters: jest.fn(), updateFilters: jest.fn(),
loadMoreRichHistory: jest.fn(), loadMoreRichHistory: jest.fn(),
clearRichHistoryResults: jest.fn(), clearRichHistoryResults: jest.fn(),
exploreId: ExploreId.left, exploreId: 'left',
richHistorySettings: { richHistorySettings: {
retentionPeriod: 7, retentionPeriod: 7,
starredTabAsFirstTab: false, starredTabAsFirstTab: false,

View File

@ -10,7 +10,7 @@ import {
RichHistorySearchFilters, RichHistorySearchFilters,
RichHistorySettings, RichHistorySettings,
} from 'app/core/utils/richHistory'; } from 'app/core/utils/richHistory';
import { RichHistoryQuery, ExploreId } from 'app/types/explore'; import { RichHistoryQuery } from 'app/types/explore';
import { getSortOrderOptions } from './RichHistory'; import { getSortOrderOptions } from './RichHistory';
import RichHistoryCard from './RichHistoryCard'; import RichHistoryCard from './RichHistoryCard';
@ -25,7 +25,7 @@ export interface RichHistoryStarredTabProps {
loadMoreRichHistory: () => void; loadMoreRichHistory: () => void;
richHistorySearchFilters?: RichHistorySearchFilters; richHistorySearchFilters?: RichHistorySearchFilters;
richHistorySettings: RichHistorySettings; richHistorySettings: RichHistorySettings;
exploreId: ExploreId; exploreId: string;
} }
const getStyles = (theme: GrafanaTheme2) => { const getStyles = (theme: GrafanaTheme2) => {

View File

@ -2,7 +2,6 @@ import { render, screen, within } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { DataFrame, FieldType, getDefaultTimeRange, InternalTimeZones, toDataFrame } from '@grafana/data'; import { DataFrame, FieldType, getDefaultTimeRange, InternalTimeZones, toDataFrame } from '@grafana/data';
import { ExploreId } from 'app/types';
import { TableContainer } from './TableContainer'; import { TableContainer } from './TableContainer';
@ -48,7 +47,7 @@ const dataFrame = toDataFrame({
}); });
const defaultProps = { const defaultProps = {
exploreId: ExploreId.left, exploreId: 'left',
loading: false, loading: false,
width: 800, width: 800,
onCellFilterAdded: jest.fn(), onCellFilterAdded: jest.fn(),

View File

@ -5,7 +5,7 @@ import { ValueLinkConfig, applyFieldOverrides, TimeZone, SplitOpen, DataFrame, L
import { Table, AdHocFilterItem, PanelChrome } from '@grafana/ui'; import { Table, AdHocFilterItem, PanelChrome } from '@grafana/ui';
import { config } from 'app/core/config'; import { config } from 'app/core/config';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { ExploreId, ExploreItemState } from 'app/types/explore'; import { ExploreItemState } from 'app/types/explore';
import { MetaInfoText } from '../MetaInfoText'; import { MetaInfoText } from '../MetaInfoText';
import { selectIsWaitingForData } from '../state/query'; import { selectIsWaitingForData } from '../state/query';
@ -13,7 +13,7 @@ import { getFieldLinksForExplore } from '../utils/links';
interface TableContainerProps { interface TableContainerProps {
ariaLabel?: string; ariaLabel?: string;
exploreId: ExploreId; exploreId: string;
width: number; width: number;
timeZone: TimeZone; timeZone: TimeZone;
onCellFilterAdded?: (filter: AdHocFilterItem) => void; onCellFilterAdded?: (filter: AdHocFilterItem) => void;

View File

@ -5,7 +5,6 @@ import { Provider } from 'react-redux';
import { DataFrame, MutableDataFrame, getDefaultTimeRange, LoadingState } from '@grafana/data'; import { DataFrame, MutableDataFrame, getDefaultTimeRange, LoadingState } from '@grafana/data';
import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime';
import { ExploreId } from 'app/types';
import { configureStore } from '../../../store/configureStore'; import { configureStore } from '../../../store/configureStore';
@ -26,7 +25,7 @@ function getTraceView(frames: DataFrame[]) {
return ( return (
<Provider store={store}> <Provider store={store}>
<TraceView <TraceView
exploreId={ExploreId.left} exploreId="left"
dataFrames={frames} dataFrames={frames}
splitOpenFn={() => {}} splitOpenFn={() => {}}
traceProp={transformDataFrames(frames[0])!} traceProp={transformDataFrames(frames[0])!}

View File

@ -24,7 +24,6 @@ import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { getTimeZone } from 'app/features/profile/state/selectors'; import { getTimeZone } from 'app/features/profile/state/selectors';
import { TempoQuery } from 'app/plugins/datasource/tempo/types'; import { TempoQuery } from 'app/plugins/datasource/tempo/types';
import { useDispatch, useSelector } from 'app/types'; import { useDispatch, useSelector } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { changePanelState } from '../state/explorePane'; import { changePanelState } from '../state/explorePane';
@ -63,7 +62,7 @@ function noop(): {} {
type Props = { type Props = {
dataFrames: DataFrame[]; dataFrames: DataFrame[];
splitOpenFn?: SplitOpen; splitOpenFn?: SplitOpen;
exploreId?: ExploreId; exploreId?: string;
scrollElement?: Element; scrollElement?: Element;
scrollElementClass?: string; scrollElementClass?: string;
traceProp: Trace; traceProp: Trace;
@ -251,7 +250,7 @@ export function TraceView(props: Props) {
* @param options * @param options
*/ */
function useFocusSpanLink(options: { function useFocusSpanLink(options: {
exploreId: ExploreId; exploreId: string;
splitOpenFn: SplitOpen; splitOpenFn: SplitOpen;
refId?: string; refId?: string;
datasource?: DataSourceApi; datasource?: DataSourceApi;

View File

@ -5,7 +5,6 @@ import { Provider } from 'react-redux';
import { getDefaultTimeRange, LoadingState } from '@grafana/data'; import { getDefaultTimeRange, LoadingState } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { ExploreId } from 'app/types';
import { configureStore } from '../../../store/configureStore'; import { configureStore } from '../../../store/configureStore';
@ -31,7 +30,7 @@ function renderTraceViewContainer(frames = [frameOld]) {
const { container, baseElement } = render( const { container, baseElement } = render(
<Provider store={store}> <Provider store={store}>
<TraceViewContainer <TraceViewContainer
exploreId={ExploreId.left} exploreId="left"
dataFrames={frames} dataFrames={frames}
splitOpenFn={() => {}} splitOpenFn={() => {}}
queryResponse={mockPanelData} queryResponse={mockPanelData}

View File

@ -5,7 +5,6 @@ import { DataFrame, SplitOpen, PanelData, GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui';
import { StoreState, useSelector } from 'app/types'; import { StoreState, useSelector } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { TraceView } from './TraceView'; import { TraceView } from './TraceView';
import TracePageSearchBar from './components/TracePageHeader/TracePageSearchBar'; import TracePageSearchBar from './components/TracePageHeader/TracePageSearchBar';
@ -15,7 +14,7 @@ import { transformDataFrames } from './utils/transform';
interface Props { interface Props {
dataFrames: DataFrame[]; dataFrames: DataFrame[];
splitOpenFn: SplitOpen; splitOpenFn: SplitOpen;
exploreId: ExploreId; exploreId: string;
scrollElement?: Element; scrollElement?: Element;
queryResponse: PanelData; queryResponse: PanelData;
topOfViewRef: RefObject<HTMLDivElement>; topOfViewRef: RefObject<HTMLDivElement>;

View File

@ -0,0 +1,96 @@
import { renderHook, waitFor } from '@testing-library/react';
import { TestProvider } from 'test/helpers/TestProvider';
import { setDataSourceSrv } from '@grafana/runtime';
import { DataSourceRef } from '@grafana/schema';
import { makeDatasourceSetup } from '../spec/helper/setup';
import { useExplorePageTitle } from './useExplorePageTitle';
describe('useExplorePageTitle', () => {
it('changes the document title of the explore page to include the datasource in use', async () => {
const datasources = [
makeDatasourceSetup({ name: 'loki', uid: 'loki-uid' }),
makeDatasourceSetup({ name: 'elastic', uid: 'elastic-uid' }),
];
setDataSourceSrv({
get(datasource?: string | DataSourceRef | null) {
let ds;
if (!datasource) {
ds = datasources[0]?.api;
} else {
ds = datasources.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();
},
getInstanceSettings: jest.fn(),
getList: jest.fn(),
reload: jest.fn(),
});
renderHook(() => useExplorePageTitle({ panes: JSON.stringify({ a: { datasource: 'loki-uid' } }) }), {
wrapper: TestProvider,
});
await waitFor(() => {
expect(global.document.title).toEqual(expect.stringContaining('loki'));
expect(global.document.title).toEqual(expect.not.stringContaining('elastic'));
});
});
it('changes the document title to include the two datasources in use in split view mode', async () => {
const datasources = [
makeDatasourceSetup({ name: 'loki', uid: 'loki-uid' }),
makeDatasourceSetup({ name: 'elastic', uid: 'elastic-uid' }),
];
setDataSourceSrv({
get(datasource?: string | DataSourceRef | null) {
let ds;
if (!datasource) {
ds = datasources[0]?.api;
} else {
ds = datasources.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();
},
getInstanceSettings: jest.fn(),
getList: jest.fn(),
reload: jest.fn(),
});
renderHook(
() =>
useExplorePageTitle({
panes: JSON.stringify({ a: { datasource: 'loki-uid' }, b: { datasource: 'elastic-uid' } }),
}),
{
wrapper: TestProvider,
}
);
await waitFor(() => {
expect(global.document.title).toEqual(expect.stringContaining('loki | elastic'));
});
});
});

View File

@ -1,16 +1,53 @@
import { isTruthy } from '@grafana/data'; import { useEffect, useRef } from 'react';
import { NavModel } from '@grafana/data';
import { Branding } from 'app/core/components/Branding/Branding'; import { Branding } from 'app/core/components/Branding/Branding';
import { useNavModel } from 'app/core/hooks/useNavModel'; import { useNavModel } from 'app/core/hooks/useNavModel';
import { useSelector } from 'app/types'; import { safeParseJson } from 'app/core/utils/explore';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { ExploreQueryParams } from 'app/types';
import { selectOrderedExplorePanes } from '../state/selectors'; import { isFulfilled, hasKey } from './utils';
export function useExplorePageTitle() { export function useExplorePageTitle(params: ExploreQueryParams) {
const navModel = useNavModel('explore'); const navModel = useRef<NavModel>();
navModel.current = useNavModel('explore');
const dsService = useRef(getDatasourceSrv());
const datasourceNames = useSelector((state) => useEffect(() => {
Object.values(selectOrderedExplorePanes(state)).map((pane) => pane?.datasourceInstance?.name) if (!params.panes || typeof params.panes !== 'string') {
).filter(isTruthy); return;
}
document.title = `${navModel.main.text} - ${datasourceNames.join(' | ')} - ${Branding.AppTitle}`; Promise.allSettled(
Object.values(safeParseJson(params.panes)).map((pane) => {
if (
!pane ||
typeof pane !== 'object' ||
!hasKey('datasource', pane) ||
!pane.datasource ||
typeof pane.datasource !== 'string'
) {
return Promise.reject();
}
return dsService.current.get(pane.datasource);
})
)
.then((results) => results.filter(isFulfilled).map((result) => result.value))
.then((datasources) => {
if (!navModel.current) {
return;
}
const names = datasources.map((ds) => ds.name);
if (names.length === 0) {
global.document.title = `${navModel.current.main.text} - ${Branding.AppTitle}`;
return;
}
global.document.title = `${navModel.current.main.text} - ${names.join(' | ')} - ${Branding.AppTitle}`;
});
}, [params.panes]);
} }

View File

@ -0,0 +1,443 @@
import { act, waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { createMemoryHistory } from 'history';
import { stringify } from 'querystring';
import React, { ReactNode } from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { UrlQueryMap } from '@grafana/data';
import { HistoryWrapper, setDataSourceSrv } from '@grafana/runtime';
import { setLastUsedDatasourceUID } from 'app/core/utils/explore';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { configureStore } from 'app/store/configureStore';
import { makeDatasourceSetup } from '../../spec/helper/setup';
import { splitClose, splitOpen } from '../../state/main';
import { useStateSync } from './';
interface SetupParams {
queryParams?: UrlQueryMap;
exploreMixedDatasource?: boolean;
}
function setup({ queryParams = {}, exploreMixedDatasource = false }: SetupParams) {
const history = createMemoryHistory({
initialEntries: [{ pathname: '/explore', search: stringify(queryParams) }],
});
const location = new HistoryWrapper(history);
const datasources = [
makeDatasourceSetup({ name: 'loki', uid: 'loki-uid' }),
makeDatasourceSetup({ name: 'elastic', uid: 'elastic-uid' }),
];
if (exploreMixedDatasource) {
datasources.push(makeDatasourceSetup({ name: MIXED_DATASOURCE_NAME, uid: MIXED_DATASOURCE_NAME, id: 999 }));
}
setDataSourceSrv({
get(datasource) {
let ds;
if (!datasource) {
ds = datasources[0]?.api;
} else {
ds = datasources.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();
},
getInstanceSettings: jest.fn(),
getList: jest.fn(),
reload: jest.fn(),
});
const store = configureStore({
user: {
orgId: 1,
fiscalYearStartMonth: 0,
isUpdating: false,
orgs: [],
orgsAreLoading: false,
sessions: [],
sessionsAreLoading: false,
teams: [],
teamsAreLoading: false,
timeZone: 'utc',
user: null,
weekStart: 'monday',
},
});
const context = getGrafanaContextMock();
const wrapper = ({ children }: { children: ReactNode }) => (
<TestProvider
grafanaContext={{
...context,
location,
config: {
...context.config,
featureToggles: {
exploreMixedDatasource,
},
},
}}
store={store}
>
{children}
</TestProvider>
);
return {
...renderHook<{ params: UrlQueryMap; children: ReactNode }, void>(({ params }) => useStateSync(params), {
wrapper,
initialProps: {
children: null,
params: queryParams,
},
}),
location,
store,
};
}
describe('useStateSync', () => {
it('does not push a new entry to history on first render', async () => {
const { location, waitForNextUpdate } = setup({});
const initialHistoryLength = location.getHistory().length;
await waitForNextUpdate();
expect(location.getHistory().length).toBe(initialHistoryLength);
const search = location.getSearchObject();
expect(search.panes).toBeDefined();
});
it('inits an explore pane for each key in the panes search object', async () => {
const { location, waitForNextUpdate, store } = setup({
queryParams: {
panes: JSON.stringify({
one: { datasource: 'loki-uid', queries: [{ datasource: { name: 'loki', uid: 'loki-uid' } }] },
two: { datasource: 'elastic-uid', queries: [{ datasource: { name: 'elastic', uid: 'elastic-uid' } }] },
}),
schemaVersion: 1,
},
});
const initialHistoryLength = location.getHistory().length;
await waitForNextUpdate();
expect(location.getHistory().length).toBe(initialHistoryLength);
const search = location.getSearchObject();
expect(search.panes).toBeDefined();
expect(Object.keys(store.getState().explore.panes)).toHaveLength(2);
});
it('inits with a default query from the root level datasource when there are no valid queries in the URL', async () => {
const { location, waitForNextUpdate, store } = setup({
queryParams: {
panes: JSON.stringify({
one: { datasource: 'loki-uid', queries: [{ datasource: { name: 'UNKNOWN', uid: 'UNKNOWN-DS' } }] },
}),
schemaVersion: 1,
},
});
const initialHistoryLength = location.getHistory().length;
await waitForNextUpdate();
expect(location.getHistory().length).toBe(initialHistoryLength);
const search = location.getSearchObject();
expect(search.panes).toBeDefined();
const queries = store.getState().explore.panes['one']?.queries;
expect(queries).toHaveLength(1);
expect(queries?.[0].datasource?.uid).toBe('loki-uid');
});
it('inits with the last used datasource from localStorage', async () => {
setLastUsedDatasourceUID(1, 'elastic-uid');
const { waitForNextUpdate, store } = setup({
queryParams: {},
});
await waitForNextUpdate();
expect(Object.values(store.getState().explore.panes)[0]?.datasourceInstance?.uid).toBe('elastic-uid');
});
it('inits with the default datasource if the last used in localStorage does not exits', async () => {
setLastUsedDatasourceUID(1, 'unknown-ds-uid');
const { waitForNextUpdate, store } = setup({
queryParams: {},
});
await waitForNextUpdate();
expect(Object.values(store.getState().explore.panes)[0]?.datasourceInstance?.uid).toBe('loki-uid');
});
it('updates the state with correct queries from URL', async () => {
const { waitForNextUpdate, rerender, store } = setup({
queryParams: {
panes: JSON.stringify({
one: { datasource: 'loki-uid', queries: [{ expr: 'a' }] },
}),
schemaVersion: 1,
},
});
await waitForNextUpdate();
let queries = store.getState().explore.panes['one']?.queries;
expect(queries).toHaveLength(1);
expect(queries?.[0]).toMatchObject({ expr: 'a' });
rerender({
children: null,
params: {
panes: JSON.stringify({
one: { datasource: 'loki-uid', queries: [{ expr: 'a' }, { expr: 'b' }] },
}),
schemaVersion: 1,
},
});
await waitForNextUpdate();
queries = store.getState().explore.panes['one']?.queries;
expect(queries).toHaveLength(2);
expect(queries?.[0]).toMatchObject({ expr: 'a' });
expect(queries?.[1]).toMatchObject({ expr: 'b' });
rerender({
children: null,
params: {
panes: JSON.stringify({
one: { datasource: 'loki-uid', queries: [{ expr: 'a' }] },
}),
schemaVersion: 1,
},
});
await waitForNextUpdate();
queries = store.getState().explore.panes['one']?.queries;
expect(queries).toHaveLength(1);
expect(queries?.[0]).toMatchObject({ expr: 'a' });
});
it('Opens and closes the split pane if an a new pane is added or removed in the URL', async () => {
const { waitForNextUpdate, rerender, store } = setup({
queryParams: {
panes: JSON.stringify({
one: { datasource: 'loki-uid', queries: [{ expr: 'a' }] },
}),
schemaVersion: 1,
},
});
await waitForNextUpdate();
let panes = Object.keys(store.getState().explore.panes);
expect(panes).toHaveLength(1);
rerender({
children: null,
params: {
panes: JSON.stringify({
one: { datasource: 'loki-uid', queries: [{ expr: 'a' }, { expr: 'b' }] },
two: { datasource: 'loki-uid', queries: [{ expr: 'a' }, { expr: 'b' }] },
}),
schemaVersion: 1,
},
});
await waitForNextUpdate();
expect(Object.keys(store.getState().explore.panes)).toHaveLength(2);
rerender({
children: null,
params: {
panes: JSON.stringify({
one: { datasource: 'loki-uid', queries: [{ expr: 'a' }] },
}),
schemaVersion: 1,
},
});
await waitForNextUpdate();
await waitFor(() => {
expect(Object.keys(store.getState().explore.panes)).toHaveLength(1);
});
});
it('Changes datasource when the datasource in the URL is updated', async () => {
const { waitForNextUpdate, rerender, store } = setup({
queryParams: {
panes: JSON.stringify({
one: { datasource: 'loki-uid', queries: [{ expr: 'a' }] },
}),
schemaVersion: 1,
},
});
await waitForNextUpdate();
expect(store.getState().explore.panes['one']?.datasourceInstance?.getRef()).toMatchObject({
type: 'logs',
uid: 'loki-uid',
});
rerender({
children: null,
params: {
panes: JSON.stringify({
one: { datasource: 'elastic-uid', queries: [{ expr: 'a' }, { expr: 'b' }] },
}),
schemaVersion: 1,
},
});
await waitForNextUpdate();
expect(store.getState().explore.panes['one']?.datasourceInstance?.getRef()).toMatchObject({
type: 'logs',
uid: 'elastic-uid',
});
});
it('Changes time rage when the range in the URL is updated', async () => {
const { waitForNextUpdate, rerender, store } = setup({
queryParams: {
panes: JSON.stringify({
one: { datasource: 'loki-uid', queries: [{ expr: 'a' }], range: { from: 'now-1h', to: 'now' } },
}),
schemaVersion: 1,
},
});
await waitForNextUpdate();
expect(store.getState().explore.panes['one']?.range.raw).toMatchObject({ from: 'now-1h', to: 'now' });
rerender({
children: null,
params: {
panes: JSON.stringify({
one: { datasource: 'loki-uid', queries: [{ expr: 'a' }], range: { from: 'now-6h', to: 'now' } },
}),
schemaVersion: 1,
},
});
await waitForNextUpdate();
expect(store.getState().explore.panes['one']?.range.raw).toMatchObject({ from: 'now-6h', to: 'now' });
});
it('uses the first query datasource if no root datasource is specified in the URL', async () => {
const { waitForNextUpdate, store } = setup({
exploreMixedDatasource: true,
queryParams: {
panes: JSON.stringify({
one: {
queries: [{ expr: 'b', datasource: { uid: 'loki-uid', type: 'logs' }, refId: 'B' }],
},
}),
schemaVersion: 1,
},
});
await waitForNextUpdate();
expect(store.getState().explore.panes['one']?.datasourceInstance?.getRef()).toMatchObject({
uid: 'loki-uid',
type: 'logs',
});
});
it('updates the URL opening and closing a pane datasource changes', async () => {
const { waitForNextUpdate, store, location } = setup({
exploreMixedDatasource: true,
queryParams: {
panes: JSON.stringify({
one: {
datasource: 'loki-uid',
queries: [{ expr: 'a', datasource: { uid: 'loki-uid', type: 'logs' }, refId: 'A' }],
},
}),
schemaVersion: 1,
},
});
await waitForNextUpdate();
expect(location.getHistory().length).toBe(1);
expect(store.getState().explore.panes['one']?.datasourceInstance?.uid).toBe('loki-uid');
act(() => {
store.dispatch(splitOpen());
});
await waitForNextUpdate();
await waitFor(async () => {
expect(location.getHistory().length).toBe(2);
});
expect(Object.keys(store.getState().explore.panes)).toHaveLength(2);
act(() => {
store.dispatch(splitClose('one'));
});
await waitFor(async () => {
expect(location.getHistory()).toHaveLength(3);
});
});
describe('with exploreMixedDatasource enabled', () => {
it('filters out queries from the URL that do not have a datasource', async () => {
const { waitForNextUpdate, store } = setup({
exploreMixedDatasource: true,
queryParams: {
panes: JSON.stringify({
one: {
datasource: MIXED_DATASOURCE_NAME,
queries: [
{ expr: 'a', refId: 'A' },
{ expr: 'b', datasource: { uid: 'loki-uid', type: 'logs' }, refId: 'B' },
],
},
}),
schemaVersion: 1,
},
});
await waitForNextUpdate();
expect(store.getState().explore.panes['one']?.queries.length).toBe(1);
expect(store.getState().explore.panes['one']?.queries[0]).toMatchObject({ expr: 'b', refId: 'B' });
});
});
});

View File

@ -1,30 +1,25 @@
import { identity, isEmpty, isEqual, isObject, mapValues, omitBy } from 'lodash'; import { identity, isEmpty, isEqual, isObject, mapValues, omitBy } from 'lodash';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { import { CoreApp, ExploreUrlState, isDateTime, TimeRange, RawTimeRange, DataSourceApi } from '@grafana/data';
CoreApp,
serializeStateToUrlParam,
ExploreUrlState,
isDateTime,
TimeRange,
RawTimeRange,
DataSourceApi,
} from '@grafana/data';
import { DataQuery, DataSourceRef } from '@grafana/schema'; import { DataQuery, DataSourceRef } from '@grafana/schema';
import { useGrafana } from 'app/core/context/GrafanaContext'; import { useGrafana } from 'app/core/context/GrafanaContext';
import { clearQueryKeys, getLastUsedDatasourceUID, parseUrlState } from 'app/core/utils/explore'; import { clearQueryKeys, getLastUsedDatasourceUID } from 'app/core/utils/explore';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { addListener, ExploreId, ExploreItemState, ExploreQueryParams, useDispatch, useSelector } from 'app/types'; import { addListener, ExploreItemState, ExploreQueryParams, useDispatch, useSelector } from 'app/types';
import { changeDatasource } from '../state/datasource'; import { changeDatasource } from '../../state/datasource';
import { initializeExplore } from '../state/explorePane'; import { initializeExplore } from '../../state/explorePane';
import { clearPanes, splitClose, splitOpen, syncTimesAction } from '../state/main'; import { clearPanes, splitClose, splitOpen, syncTimesAction } from '../../state/main';
import { runQueries, setQueriesAction } from '../state/query'; import { runQueries, setQueriesAction } from '../../state/query';
import { selectPanes } from '../state/selectors'; import { selectPanes } from '../../state/selectors';
import { changeRangeAction, updateTime } from '../state/time'; import { changeRangeAction, updateTime } from '../../state/time';
import { DEFAULT_RANGE } from '../state/utils'; import { DEFAULT_RANGE } from '../../state/utils';
import { withUniqueRefIds } from '../utils/queries'; import { withUniqueRefIds } from '../../utils/queries';
import { isFulfilled } from '../utils';
import { parseURL } from './parseURL';
/** /**
* Bi-directionally syncs URL changes with Explore's state. * Bi-directionally syncs URL changes with Explore's state.
@ -37,15 +32,15 @@ export function useStateSync(params: ExploreQueryParams) {
}, },
} = useGrafana(); } = useGrafana();
const dispatch = useDispatch(); const dispatch = useDispatch();
const statePanes = useSelector(selectPanes); const panesState = useSelector(selectPanes);
const orgId = useSelector((state) => state.user.orgId); const orgId = useSelector((state) => state.user.orgId);
const prevParams = useRef<ExploreQueryParams>(params); const prevParams = useRef(params);
const initState = useRef<'notstarted' | 'pending' | 'done'>('notstarted'); const initState = useRef<'notstarted' | 'pending' | 'done'>('notstarted');
useEffect(() => { useEffect(() => {
// This happens when the user navigates to an explore "empty page" while within Explore. // This happens when the user navigates to an explore "empty page" while within Explore.
// ie. by clicking on the explore when explore is active. // ie. by clicking on the explore when explore is active.
if (!params.left && !params.right) { if (!params.panes) {
initState.current = 'notstarted'; initState.current = 'notstarted';
prevParams.current = params; prevParams.current = params;
} }
@ -68,22 +63,27 @@ export function useStateSync(params: ExploreQueryParams) {
cancelActiveListeners(); cancelActiveListeners();
await delay(200); await delay(200);
const panesQueryParams = Object.entries(getState().explore.panes).reduce<Record<string, string>>( const panesQueryParams = Object.entries(getState().explore.panes).reduce((acc, [id, paneState]) => {
(acc, [id, paneState]) => ({ ...acc, [id]: serializeStateToUrlParam(getUrlStateFromPaneState(paneState)) }), if (!paneState) {
{} return acc;
); }
return {
...acc,
[id]: getUrlStateFromPaneState(paneState),
};
}, {});
if (!isEqual(prevParams.current, panesQueryParams)) { if (!isEqual(prevParams.current.panes, JSON.stringify(panesQueryParams))) {
// If there's no previous state it means we are mounting explore for the first time, // 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. // 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; const replace =
!!prevParams.current.panes && Object.values(prevParams.current.panes).filter(Boolean).length === 0;
prevParams.current = panesQueryParams; prevParams.current = {
panes: JSON.stringify(panesQueryParams),
};
location.partial( location.partial({ panes: prevParams.current.panes }, replace);
{ left: panesQueryParams.left, right: panesQueryParams.right, orgId: getState().user.orgId },
replace
);
} }
}, },
}) })
@ -94,33 +94,29 @@ export function useStateSync(params: ExploreQueryParams) {
}, [dispatch, location]); }, [dispatch, location]);
useEffect(() => { useEffect(() => {
const isURLOutOfSync = prevParams.current?.left !== params.left || prevParams.current?.right !== params.right; const isURLOutOfSync = prevParams.current?.panes !== params.panes;
const urlPanes = { const urlState = parseURL(params);
left: parseUrlState(params.left),
...(params.right && { right: parseUrlState(params.right) }),
};
async function sync() { async function sync() {
// if navigating the history causes one of the time range to not being equal to all the other ones, // 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. // we set syncedTimes to false to avoid inconsistent UI state.
// Ideally `syncedTimes` should be saved in the URL. // Ideally `syncedTimes` should be saved in the URL.
if (Object.values(urlPanes).some(({ range }, _, [{ range: firstRange }]) => !isEqual(range, firstRange))) { if (Object.values(urlState.panes).some(({ range }, _, [{ range: firstRange }]) => !isEqual(range, firstRange))) {
dispatch(syncTimesAction({ syncedTimes: false })); dispatch(syncTimesAction({ syncedTimes: false }));
} }
for (const [exploreId, urlPane] of Object.entries(urlPanes) as Array<[ExploreId, ExploreUrlState]>) { Object.entries(urlState.panes).forEach(([exploreId, urlPane], i) => {
const { datasource, queries, range, panelsState } = urlPane; const { datasource, queries, range, panelsState } = urlPane;
const statePane = statePanes[exploreId]; const paneState = panesState[exploreId];
if (statePane !== undefined) { if (paneState !== undefined) {
// TODO: the diff contains panelState updates, but we are currently not handling them. const update = urlDiff(urlPane, getUrlStateFromPaneState(paneState));
const update = urlDiff(urlPane, getUrlStateFromPaneState(statePane));
Promise.resolve() Promise.resolve()
.then(async () => { .then(async () => {
if (update.datasource) { if (update.datasource && datasource) {
await dispatch(changeDatasource(exploreId, datasource)); await dispatch(changeDatasource(exploreId, datasource));
} }
return; return;
@ -146,20 +142,21 @@ export function useStateSync(params: ExploreQueryParams) {
dispatch( dispatch(
initializeExplore({ initializeExplore({
exploreId, exploreId,
datasource, datasource: datasource || '',
queries: withUniqueRefIds(queries), queries: withUniqueRefIds(queries),
range, range,
panelsState, panelsState,
position: i,
}) })
); );
} }
} });
// Close all the panes that are not in the URL but are still in the store // 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. // ie. because the user has navigated back after opening the split view.
Object.keys(statePanes) Object.keys(panesState)
.filter((keyInStore) => !Object.keys(urlPanes).includes(keyInStore)) .filter((keyInStore) => !Object.keys(urlState.panes).includes(keyInStore))
.forEach((paneId) => dispatch(splitClose(paneId as ExploreId))); .forEach((paneId) => dispatch(splitClose(paneId)));
} }
// This happens when the user first navigates to explore. // This happens when the user first navigates to explore.
@ -172,11 +169,11 @@ export function useStateSync(params: ExploreQueryParams) {
dispatch(clearPanes()); dispatch(clearPanes());
Promise.all( Promise.all(
Object.entries(urlPanes).map(([exploreId, { datasource, queries, range, panelsState }]) => { Object.entries(urlState.panes).map(([exploreId, { datasource, queries, range, panelsState }]) => {
return getPaneDatasource(datasource, queries, orgId, !!exploreMixedDatasource).then( return getPaneDatasource(datasource, queries, orgId, !!exploreMixedDatasource).then(
async (paneDatasource) => { async (paneDatasource) => {
return Promise.resolve( return Promise.resolve(
// In theory, given the Grafana datasource will always be present, this should always be defined. // FIXME: In theory, given the Grafana datasource will always be present, this should always be defined.
paneDatasource paneDatasource
? queries.length ? queries.length
? // if we have queries in the URL, we use them ? // if we have queries in the URL, we use them
@ -212,7 +209,7 @@ export function useStateSync(params: ExploreQueryParams) {
.then((queries) => { .then((queries) => {
return dispatch( return dispatch(
initializeExplore({ initializeExplore({
exploreId: exploreId as ExploreId, exploreId,
datasource: paneDatasource, datasource: paneDatasource,
queries, queries,
range, range,
@ -224,25 +221,39 @@ export function useStateSync(params: ExploreQueryParams) {
); );
}) })
).then((panes) => { ).then((panes) => {
const urlState = panes.reduce<ExploreQueryParams>((acc, { exploreId, state }) => { const newParams = panes.reduce(
return { ...acc, [exploreId]: serializeStateToUrlParam(getUrlStateFromPaneState(state)) }; (acc, { exploreId, state }) => {
}, {}); return {
...acc,
location.partial({ ...urlState, orgId }, true); panes: {
...acc.panes,
[exploreId]: getUrlStateFromPaneState(state),
},
};
},
{
panes: {},
}
);
initState.current = 'done'; initState.current = 'done';
location.replace({
search: Object.entries({
panes: JSON.stringify(newParams.panes),
schemaVersion: urlState.schemaVersion,
orgId,
})
.map(([key, value]) => `${key}=${value}`)
.join('&'),
});
}); });
} }
prevParams.current = { prevParams.current = params;
left: params.left,
};
if (params.right) {
prevParams.current.right = params.right;
}
isURLOutOfSync && initState.current === 'done' && sync(); isURLOutOfSync && initState.current === 'done' && sync();
}, [params.left, params.right, dispatch, statePanes, exploreMixedDatasource, orgId, location]); }, [dispatch, panesState, exploreMixedDatasource, orgId, location, params]);
} }
function getDefaultQuery(ds: DataSourceApi) { function getDefaultQuery(ds: DataSourceApi) {
@ -342,9 +353,6 @@ async function getPaneDatasource(
); );
} }
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 * Compare 2 explore urls and return a map of what changed. Used to update the local state with all the
* side effects needed. * side effects needed.
@ -396,7 +404,7 @@ function pruneObject(obj: object): object | undefined {
return pruned; return pruned;
} }
export const toRawTimeRange = (range: TimeRange): RawTimeRange => { const toRawTimeRange = (range: TimeRange): RawTimeRange => {
let from = range.raw.from; let from = range.raw.from;
if (isDateTime(from)) { if (isDateTime(from)) {
from = from.valueOf().toString(10); from = from.valueOf().toString(10);

View File

@ -0,0 +1,16 @@
import { UrlQueryMap } from '@grafana/data';
export interface MigrationHandler<From extends BaseExploreURL | never, To> {
/**
* The parse function is used to parse the URL parameters into the state object.
*/
parse: (params: UrlQueryMap) => To;
/**
* the migrate function takes a state object from the previous schema version and returns a new state object
*/
migrate?: From extends never ? never : (from: From) => To;
}
export interface BaseExploreURL {
schemaVersion: number;
}

View File

@ -0,0 +1,125 @@
import { DEFAULT_RANGE } from 'app/features/explore/state/utils';
import { v0Migrator } from './v0';
describe('v0 migrator', () => {
describe('parse', () => {
beforeEach(function () {
jest.spyOn(console, 'error').mockImplementation(() => void 0);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('returns default state on empty string', () => {
expect(v0Migrator.parse({})).toMatchObject({
left: {
datasource: null,
queries: [],
range: DEFAULT_RANGE,
},
});
});
it('returns a valid Explore state from URL parameter', () => {
const paramValue = '{"datasource":"Local","queries":[{"expr":"metric"}],"range":{"from":"now-1h","to":"now"}}';
expect(v0Migrator.parse({ left: paramValue })).toMatchObject({
left: {
datasource: 'Local',
queries: [{ expr: 'metric' }],
range: {
from: 'now-1h',
to: 'now',
},
},
});
});
it('returns a valid Explore state from right URL parameter', () => {
const paramValue = '{"datasource":"Local","queries":[{"expr":"metric"}],"range":{"from":"now-1h","to":"now"}}';
expect(v0Migrator.parse({ right: paramValue })).toMatchObject({
right: {
datasource: 'Local',
queries: [{ expr: 'metric' }],
range: {
from: 'now-1h',
to: 'now',
},
},
});
});
it('returns a default state from invalid right URL parameter', () => {
const paramValue = 10;
expect(v0Migrator.parse({ right: paramValue })).toMatchObject({
right: {
datasource: null,
queries: [],
range: DEFAULT_RANGE,
},
});
});
it('returns a valid Explore state from a compact URL parameter', () => {
const paramValue =
'["now-1h","now","Local",{"expr":"metric"},{"ui":[true,true,true,"none"],"__panelsState":{"logs":"1"}}]';
expect(v0Migrator.parse({ left: paramValue })).toMatchObject({
left: {
datasource: 'Local',
queries: [{ expr: 'metric' }],
range: {
from: 'now-1h',
to: 'now',
},
panelsState: {
logs: '1',
},
},
});
});
it('returns default state on compact URLs with too few segments ', () => {
const paramValue = '["now-1h",{"expr":"metric"},{"ui":[true,true,true,"none"]}]';
expect(v0Migrator.parse({ left: paramValue })).toMatchObject({
left: {
datasource: null,
queries: [],
range: DEFAULT_RANGE,
},
});
expect(console.error).toHaveBeenCalledWith('Error parsing compact URL state for Explore.');
});
it('should not return a query for mode in the url', () => {
// Previous versions of Grafana included "Explore mode" in the URL; this should not be treated as a query.
const paramValue =
'["now-1h","now","x-ray-datasource",{"queryType":"getTraceSummaries"},{"mode":"Metrics"},{"ui":[true,true,true,"none"]}]';
expect(v0Migrator.parse({ left: paramValue })).toMatchObject({
left: {
datasource: 'x-ray-datasource',
queries: [{ queryType: 'getTraceSummaries' }],
range: {
from: 'now-1h',
to: 'now',
},
},
});
});
it('should return queries if queryType is present in the url', () => {
const paramValue =
'["now-1h","now","x-ray-datasource",{"queryType":"getTraceSummaries"},{"ui":[true,true,true,"none"]}]';
expect(v0Migrator.parse({ left: paramValue })).toMatchObject({
left: {
datasource: 'x-ray-datasource',
queries: [{ queryType: 'getTraceSummaries' }],
range: {
from: 'now-1h',
to: 'now',
},
},
});
});
});
});

View File

@ -0,0 +1,66 @@
import { ExploreUrlState } from '@grafana/data';
import { safeParseJson } from 'app/core/utils/explore';
import { DEFAULT_RANGE } from 'app/features/explore/state/utils';
import { BaseExploreURL, MigrationHandler } from './types';
export interface ExploreURLV0 extends BaseExploreURL {
schemaVersion: 0;
left: ExploreUrlState;
right?: ExploreUrlState;
}
export const v0Migrator: MigrationHandler<never, ExploreURLV0> = {
parse: (params) => {
return {
schemaVersion: 0,
left: parseUrlState(typeof params.left === 'string' ? params.left : undefined),
...(params.right && {
right: parseUrlState(typeof params.right === 'string' ? params.right : undefined),
}),
};
},
};
const isSegment = (segment: { [key: string]: string }, ...props: string[]) =>
props.some((prop) => segment.hasOwnProperty(prop));
enum ParseUrlStateIndex {
RangeFrom = 0,
RangeTo = 1,
Datasource = 2,
SegmentsStart = 3,
}
function parseUrlState(initial: string | undefined): ExploreUrlState {
const parsed = safeParseJson(initial);
const errorResult = {
datasource: null,
queries: [],
range: DEFAULT_RANGE,
};
if (!parsed) {
return errorResult;
}
if (!Array.isArray(parsed)) {
return { queries: [], range: DEFAULT_RANGE, ...parsed };
}
if (parsed.length <= ParseUrlStateIndex.SegmentsStart) {
console.error('Error parsing compact URL state for Explore.');
return errorResult;
}
const range = {
from: parsed[ParseUrlStateIndex.RangeFrom],
to: parsed[ParseUrlStateIndex.RangeTo],
};
const datasource = parsed[ParseUrlStateIndex.Datasource];
const parsedSegments = parsed.slice(ParseUrlStateIndex.SegmentsStart);
const queries = parsedSegments.filter((segment) => !isSegment(segment, 'ui', 'mode', '__panelsState'));
const panelsState = parsedSegments.find((segment) => isSegment(segment, '__panelsState'))?.__panelsState;
return { datasource, queries, range, panelsState };
}

View File

@ -0,0 +1,118 @@
import { DEFAULT_RANGE } from 'app/features/explore/state/utils';
import { v1Migrator } from './v1';
jest.mock('app/core/utils/explore', () => ({
...jest.requireActual('app/core/utils/explore'),
generateExploreId: () => 'ID',
}));
describe('v1 migrator', () => {
describe('parse', () => {
beforeEach(function () {
jest.spyOn(console, 'error').mockImplementation(() => void 0);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('correctly returns default state when no params are provided', () => {
expect(v1Migrator.parse({})).toMatchObject({
panes: {
ID: {
datasource: null,
queries: [],
range: DEFAULT_RANGE,
},
},
});
});
it('correctly returns default state when panes param is an empty object', () => {
expect(v1Migrator.parse({ panes: '{}' })).toMatchObject({
panes: {
ID: {
datasource: null,
queries: [],
range: DEFAULT_RANGE,
},
},
});
});
it('correctly returns default state when panes param is not a valid JSON object', () => {
expect(v1Migrator.parse({ panes: '{a malformed json}' })).toMatchObject({
panes: {
ID: {
datasource: null,
queries: [],
range: DEFAULT_RANGE,
},
},
});
expect(console.error).toHaveBeenCalledTimes(1);
});
it('correctly returns default state when a pane in panes params is an empty object', () => {
expect(v1Migrator.parse({ panes: '{"aaa": {}}' })).toMatchObject({
panes: {
aaa: {
datasource: null,
queries: [],
range: DEFAULT_RANGE,
},
},
});
});
it('correctly returns default state when a pane in panes params is not a valid JSON object', () => {
expect(v1Migrator.parse({ panes: '{"aaa": "NOT A VALID URL STATE"}' })).toMatchObject({
panes: {
aaa: {
datasource: null,
queries: [],
range: DEFAULT_RANGE,
},
},
});
});
it('correctly parses state', () => {
expect(
v1Migrator.parse({
panes: `{
"aaa": {
"datasource": "my-ds",
"queries": [
{
"refId": "A"
}
],
"range": {
"from": "now",
"to": "now-5m"
}
}
}`,
})
).toMatchObject({
panes: {
aaa: {
datasource: 'my-ds',
queries: [{ refId: 'A' }],
range: {
from: 'now',
to: 'now-5m',
},
},
},
});
});
});
describe('migrate', () => {
// TODO: implement
});
});

View File

@ -0,0 +1,89 @@
import { ExploreUrlState } from '@grafana/data';
import { generateExploreId, safeParseJson } from 'app/core/utils/explore';
import { DEFAULT_RANGE } from 'app/features/explore/state/utils';
import { hasKey } from '../../utils';
import { BaseExploreURL, MigrationHandler } from './types';
import { ExploreURLV0 } from './v0';
export interface ExploreURLV1 extends BaseExploreURL {
schemaVersion: 1;
panes: {
[id: string]: ExploreUrlState;
};
}
export const v1Migrator: MigrationHandler<ExploreURLV0, ExploreURLV1> = {
parse: (params) => {
if (!params || !params.panes || typeof params.panes !== 'string') {
return {
schemaVersion: 1,
panes: {
[generateExploreId()]: DEFAULT_STATE,
},
};
}
const rawPanes: Record<string, unknown> = safeParseJson(params.panes) || {};
const panes = Object.entries(rawPanes)
.map(([key, value]) => [key, applyDefaults(value)] as const)
.reduce<Record<string, ExploreUrlState>>((acc, [key, value]) => {
return {
...acc,
[key]: value,
};
}, {});
if (!Object.keys(panes).length) {
panes[generateExploreId()] = DEFAULT_STATE;
}
return {
schemaVersion: 1,
panes,
};
},
migrate: (params) => {
return {
schemaVersion: 1,
panes: {
[generateExploreId()]: params.left,
...(params.right && { [generateExploreId()]: params.right }),
},
};
},
};
const DEFAULT_STATE: ExploreUrlState = {
datasource: null,
queries: [],
range: DEFAULT_RANGE,
};
function applyDefaults(input: unknown): ExploreUrlState {
if (!input || typeof input !== 'object') {
return DEFAULT_STATE;
}
return {
...DEFAULT_STATE,
// queries
...(hasKey('queries', input) && Array.isArray(input.queries) && { queries: input.queries }),
// datasource
...(hasKey('datasource', input) && typeof input.datasource === 'string' && { datasource: input.datasource }),
// panelsState
...(hasKey('panelsState', input) &&
!!input.panelsState &&
typeof input.panelsState === 'object' && { panelsState: input.panelsState }),
// range
...(hasKey('range', input) &&
!!input.range &&
typeof input.range === 'object' &&
hasKey('from', input.range) &&
hasKey('to', input.range) &&
typeof input.range.from === 'string' &&
typeof input.range.to === 'string' && { range: { from: input.range.from, to: input.range.to } }),
};
}

View File

@ -0,0 +1,44 @@
import { ExploreQueryParams } from 'app/types';
import { v0Migrator } from './migrators/v0';
import { ExploreURLV1, v1Migrator } from './migrators/v1';
type ExploreURL = ExploreURLV1;
export const parseURL = (params: ExploreQueryParams) => {
return migrate(params);
};
const migrators = [v0Migrator, v1Migrator] as const;
const migrate = (params: ExploreQueryParams): ExploreURL => {
const schemaVersion = getSchemaVersion(params);
const [parser, ...migratorsToRun] = migrators.slice(schemaVersion);
const parsedUrl = parser.parse(params);
// @ts-expect-error
const final: ExploreURL = migratorsToRun.reduce((acc, migrator) => {
// @ts-expect-error
return migrator.migrate ? migrator.migrate(acc) : acc;
}, parsedUrl);
return final;
};
function getSchemaVersion(params: ExploreQueryParams): number {
if (!params || !('schemaVersion' in params) || !params.schemaVersion) {
return 0;
}
if (typeof params.schemaVersion === 'number') {
return params.schemaVersion;
}
if (typeof params.schemaVersion === 'string') {
return Number.parseInt(params.schemaVersion, 10);
}
return 0;
}

View File

@ -1,23 +0,0 @@
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,45 @@
import { renderHook, waitFor } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { stringify } from 'querystring';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { HistoryWrapper } from '@grafana/runtime';
import { useTimeSrvFix } from './useTimeSrvFix';
describe('useTimeSrvFix', () => {
it('removes `from` and `to` parameters from url when first mounted', async () => {
const history = createMemoryHistory({
initialEntries: [{ pathname: '/explore', search: stringify({ from: '1', to: '2' }) }],
});
const location = new HistoryWrapper(history);
const context = getGrafanaContextMock();
renderHook(() => useTimeSrvFix(), {
wrapper: ({ children }) => (
<TestProvider
grafanaContext={{
...context,
location,
config: {
...context.config,
featureToggles: {
exploreMixedDatasource: true,
},
},
}}
>
{children}
</TestProvider>
),
});
await waitFor(() => {
expect(location.getSearchObject()).toEqual(expect.not.objectContaining({ from: '1', to: '2' }));
});
});
});

View File

@ -15,6 +15,7 @@ import { useGrafana } from 'app/core/context/GrafanaContext';
*/ */
export function useTimeSrvFix() { export function useTimeSrvFix() {
const { location } = useGrafana(); const { location } = useGrafana();
useEffect(() => { useEffect(() => {
const searchParams = location.getSearchObject(); const searchParams = location.getSearchObject();
if (searchParams.from || searchParams.to) { if (searchParams.from || searchParams.to) {

View File

@ -0,0 +1,7 @@
export const isFulfilled = <T>(promise: PromiseSettledResult<T>): promise is PromiseFulfilledResult<T> =>
promise.status === 'fulfilled';
// TS<5 does not support `in` operator for type narrowing. once we upgrade to TS5, we can remove this function and just use the in operator instead.
export function hasKey<K extends string, T extends object>(k: K, o: T): o is T & Record<K, unknown> {
return k in o;
}

View File

@ -1,7 +1,5 @@
import { screen, waitFor } from '@testing-library/react'; import { screen, waitFor } from '@testing-library/react';
import { serializeStateToUrlParam } from '@grafana/data';
import { changeDatasource } from './helper/interactions'; import { changeDatasource } from './helper/interactions';
import { makeLogsQueryResponse } from './helper/query'; import { makeLogsQueryResponse } from './helper/query';
import { setupExplore, tearDown, waitForExplore } from './helper/setup'; import { setupExplore, tearDown, waitForExplore } from './helper/setup';
@ -15,25 +13,14 @@ describe('Explore: handle datasource states', () => {
await waitFor(() => screen.getByText(/Explore requires at least one data source/i)); await waitFor(() => screen.getByText(/Explore requires at least one data source/i));
}); });
it('handles changing the datasource manually', async () => { it('handles datasource changes', async () => {
const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}', refId: 'A' }]) }; const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}', refId: 'A' }]) };
const { datasources, location } = setupExplore({ urlParams }); const { datasources } = setupExplore({ urlParams });
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse()); jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
await waitForExplore(); await waitForExplore();
await changeDatasource('elastic'); await changeDatasource('elastic');
await screen.findByText('elastic Editor input:'); await screen.findByText('elastic Editor input:');
expect(datasources.elastic.query).not.toBeCalled(); expect(datasources.elastic.query).not.toBeCalled();
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,10 +1,8 @@
import { waitFor } from '@testing-library/react'; import { waitFor } from '@testing-library/react';
import { ExploreId } from '../../../../types';
import { withinExplore } from './setup'; import { withinExplore } from './setup';
export const assertQueryHistoryExists = async (query: string, exploreId: ExploreId = ExploreId.left) => { export const assertQueryHistoryExists = async (query: string, exploreId = 'left') => {
const selector = withinExplore(exploreId); const selector = withinExplore(exploreId);
expect(await selector.findByText('1 queries')).toBeInTheDocument(); expect(await selector.findByText('1 queries')).toBeInTheDocument();
@ -12,7 +10,7 @@ export const assertQueryHistoryExists = async (query: string, exploreId: Explore
expect(queryItem).toHaveTextContent(query); expect(queryItem).toHaveTextContent(query);
}; };
export const assertQueryHistory = async (expectedQueryTexts: string[], exploreId: ExploreId = ExploreId.left) => { export const assertQueryHistory = async (expectedQueryTexts: string[], exploreId = 'left') => {
const selector = withinExplore(exploreId); const selector = withinExplore(exploreId);
await waitFor(() => { await waitFor(() => {
expect(selector.getByText(new RegExp(`${expectedQueryTexts.length} queries`))).toBeInTheDocument(); expect(selector.getByText(new RegExp(`${expectedQueryTexts.length} queries`))).toBeInTheDocument();
@ -23,10 +21,7 @@ export const assertQueryHistory = async (expectedQueryTexts: string[], exploreId
}); });
}; };
export const assertQueryHistoryComment = async ( export const assertQueryHistoryComment = async (expectedQueryComments: string[], exploreId = 'left') => {
expectedQueryComments: string[],
exploreId: ExploreId = ExploreId.left
) => {
const selector = withinExplore(exploreId); const selector = withinExplore(exploreId);
await waitFor(() => { await waitFor(() => {
expect(selector.getByText(new RegExp(`${expectedQueryComments.length} queries`))).toBeInTheDocument(); expect(selector.getByText(new RegExp(`${expectedQueryComments.length} queries`))).toBeInTheDocument();
@ -37,7 +32,7 @@ export const assertQueryHistoryComment = async (
}); });
}; };
export const assertQueryHistoryIsStarred = async (expectedStars: boolean[], exploreId: ExploreId = ExploreId.left) => { export const assertQueryHistoryIsStarred = async (expectedStars: boolean[], exploreId = 'left') => {
const selector = withinExplore(exploreId); const selector = withinExplore(exploreId);
const starButtons = selector.getAllByRole('button', { name: /Star query|Unstar query/ }); const starButtons = selector.getAllByRole('button', { name: /Star query|Unstar query/ });
await waitFor(() => await waitFor(() =>
@ -49,12 +44,12 @@ export const assertQueryHistoryIsStarred = async (expectedStars: boolean[], expl
export const assertQueryHistoryTabIsSelected = ( export const assertQueryHistoryTabIsSelected = (
tabName: 'Query history' | 'Starred' | 'Settings', tabName: 'Query history' | 'Starred' | 'Settings',
exploreId: ExploreId = ExploreId.left exploreId = 'left'
) => { ) => {
expect(withinExplore(exploreId).getByRole('tab', { name: `Tab ${tabName}`, selected: true })).toBeInTheDocument(); expect(withinExplore(exploreId).getByRole('tab', { name: `Tab ${tabName}`, selected: true })).toBeInTheDocument();
}; };
export const assertDataSourceFilterVisibility = (visible: boolean, exploreId: ExploreId = ExploreId.left) => { export const assertDataSourceFilterVisibility = (visible: boolean, exploreId = 'left') => {
const filterInput = withinExplore(exploreId).queryByLabelText('Filter queries for data sources(s)'); const filterInput = withinExplore(exploreId).queryByLabelText('Filter queries for data sources(s)');
if (visible) { if (visible) {
expect(filterInput).toBeInTheDocument(); expect(filterInput).toBeInTheDocument();
@ -63,14 +58,10 @@ export const assertDataSourceFilterVisibility = (visible: boolean, exploreId: Ex
} }
}; };
export const assertQueryHistoryElementsShown = ( export const assertQueryHistoryElementsShown = (shown: number, total: number, exploreId = 'left') => {
shown: number,
total: number,
exploreId: ExploreId = ExploreId.left
) => {
expect(withinExplore(exploreId).queryByText(`Showing ${shown} of ${total}`)).toBeInTheDocument(); expect(withinExplore(exploreId).queryByText(`Showing ${shown} of ${total}`)).toBeInTheDocument();
}; };
export const assertLoadMoreQueryHistoryNotVisible = (exploreId: ExploreId = ExploreId.left) => { export const assertLoadMoreQueryHistoryNotVisible = (exploreId = 'left') => {
expect(withinExplore(exploreId).queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument(); expect(withinExplore(exploreId).queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
}; };

View File

@ -3,8 +3,6 @@ import userEvent from '@testing-library/user-event';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { ExploreId } from '../../../../types';
import { withinExplore } from './setup'; import { withinExplore } from './setup';
export const changeDatasource = async (name: string) => { export const changeDatasource = async (name: string) => {
@ -14,59 +12,52 @@ export const changeDatasource = async (name: string) => {
fireEvent.click(option); fireEvent.click(option);
}; };
export const inputQuery = async (query: string, exploreId: ExploreId = ExploreId.left) => { export const inputQuery = async (query: string, exploreId = 'left') => {
const input = withinExplore(exploreId).getByRole('textbox', { name: 'query' }); const input = withinExplore(exploreId).getByRole('textbox', { name: 'query' });
await userEvent.clear(input); await userEvent.clear(input);
await userEvent.type(input, query); await userEvent.type(input, query);
}; };
export const runQuery = async (exploreId: ExploreId = ExploreId.left) => { export const runQuery = async (exploreId = 'left') => {
const explore = withinExplore(exploreId); const explore = withinExplore(exploreId);
const toolbar = within(explore.getByLabelText('Explore toolbar')); const toolbar = within(explore.getByLabelText('Explore toolbar'));
const button = toolbar.getByRole('button', { name: /run query/i }); const button = toolbar.getByRole('button', { name: /run query/i });
await userEvent.click(button); await userEvent.click(button);
}; };
export const openQueryHistory = async (exploreId: ExploreId = ExploreId.left) => { export const openQueryHistory = async (exploreId = 'left') => {
const selector = withinExplore(exploreId); const selector = withinExplore(exploreId);
const button = selector.getByRole('button', { name: 'Rich history button' }); const button = selector.getByRole('button', { name: 'Rich history button' });
await userEvent.click(button); await userEvent.click(button);
expect(await selector.findByPlaceholderText('Search queries')).toBeInTheDocument(); expect(await selector.findByPlaceholderText('Search queries')).toBeInTheDocument();
}; };
export const closeQueryHistory = async (exploreId: ExploreId = ExploreId.left) => { export const closeQueryHistory = async (exploreId = 'left') => {
const closeButton = withinExplore(exploreId).getByRole('button', { name: 'Close query history' }); const closeButton = withinExplore(exploreId).getByRole('button', { name: 'Close query history' });
await userEvent.click(closeButton); await userEvent.click(closeButton);
}; };
export const switchToQueryHistoryTab = async ( export const switchToQueryHistoryTab = async (name: 'Settings' | 'Query History', exploreId = 'left') => {
name: 'Settings' | 'Query History',
exploreId: ExploreId = ExploreId.left
) => {
await userEvent.click(withinExplore(exploreId).getByRole('tab', { name: `Tab ${name}` })); await userEvent.click(withinExplore(exploreId).getByRole('tab', { name: `Tab ${name}` }));
}; };
export const selectStarredTabFirst = async (exploreId: ExploreId = ExploreId.left) => { export const selectStarredTabFirst = async (exploreId = 'left') => {
const checkbox = withinExplore(exploreId).getByRole('checkbox', { const checkbox = withinExplore(exploreId).getByRole('checkbox', {
name: /Change the default active tab from “Query history” to “Starred”/, name: /Change the default active tab from “Query history” to “Starred”/,
}); });
await userEvent.click(checkbox); await userEvent.click(checkbox);
}; };
export const selectOnlyActiveDataSource = async (exploreId: ExploreId = ExploreId.left) => { export const selectOnlyActiveDataSource = async (exploreId = 'left') => {
const checkbox = withinExplore(exploreId).getByLabelText(/Only show queries for data source currently active.*/); const checkbox = withinExplore(exploreId).getByLabelText(/Only show queries for data source currently active.*/);
await userEvent.click(checkbox); await userEvent.click(checkbox);
}; };
export const starQueryHistory = async (queryIndex: number, exploreId: ExploreId = ExploreId.left) => { export const starQueryHistory = async (queryIndex: number, exploreId = 'left') => {
await invokeAction(queryIndex, 'Star query', exploreId); await invokeAction(queryIndex, 'Star query', exploreId);
}; };
export const commentQueryHistory = async ( export const commentQueryHistory = async (queryIndex: number, comment: string, exploreId = 'left') => {
queryIndex: number,
comment: string,
exploreId: ExploreId = ExploreId.left
) => {
await invokeAction(queryIndex, 'Add comment', exploreId); await invokeAction(queryIndex, 'Add comment', exploreId);
const input = withinExplore(exploreId).getByPlaceholderText('An optional description of what the query does.'); const input = withinExplore(exploreId).getByPlaceholderText('An optional description of what the query does.');
await userEvent.clear(input); await userEvent.clear(input);
@ -74,16 +65,16 @@ export const commentQueryHistory = async (
await invokeAction(queryIndex, 'Save comment', exploreId); await invokeAction(queryIndex, 'Save comment', exploreId);
}; };
export const deleteQueryHistory = async (queryIndex: number, exploreId: ExploreId = ExploreId.left) => { export const deleteQueryHistory = async (queryIndex: number, exploreId = 'left') => {
await invokeAction(queryIndex, 'Delete query', exploreId); await invokeAction(queryIndex, 'Delete query', exploreId);
}; };
export const loadMoreQueryHistory = async (exploreId: ExploreId = ExploreId.left) => { export const loadMoreQueryHistory = async (exploreId = 'left') => {
const button = withinExplore(exploreId).getByRole('button', { name: 'Load more' }); const button = withinExplore(exploreId).getByRole('button', { name: 'Load more' });
await userEvent.click(button); await userEvent.click(button);
}; };
const invokeAction = async (queryIndex: number, actionAccessibleName: string, exploreId: ExploreId) => { const invokeAction = async (queryIndex: number, actionAccessibleName: string, exploreId: string) => {
const selector = withinExplore(exploreId); const selector = withinExplore(exploreId);
const buttons = selector.getAllByRole('button', { name: actionAccessibleName }); const buttons = selector.getAllByRole('button', { name: actionAccessibleName });
await userEvent.click(buttons[queryIndex]); await userEvent.click(buttons[queryIndex]);

View File

@ -33,7 +33,7 @@ import { configureStore } from 'app/store/configureStore';
import { LokiDatasource } from '../../../../plugins/datasource/loki/datasource'; import { LokiDatasource } from '../../../../plugins/datasource/loki/datasource';
import { LokiQuery } from '../../../../plugins/datasource/loki/types'; import { LokiQuery } from '../../../../plugins/datasource/loki/types';
import { ExploreId, ExploreQueryParams } from '../../../../types'; import { ExploreQueryParams } from '../../../../types';
import { initialUserState } from '../../../profile/state/reducers'; import { initialUserState } from '../../../profile/state/reducers';
import ExplorePage from '../../ExplorePage'; import ExplorePage from '../../ExplorePage';
@ -42,7 +42,7 @@ type DatasourceSetup = { settings: DataSourceInstanceSettings; api: DataSourceAp
type SetupOptions = { type SetupOptions = {
clearLocalStorage?: boolean; clearLocalStorage?: boolean;
datasources?: DatasourceSetup[]; datasources?: DatasourceSetup[];
urlParams?: ExploreQueryParams & { [key: string]: string }; urlParams?: ExploreQueryParams;
prevUsedDatasource?: { orgId: number; datasource: string }; prevUsedDatasource?: { orgId: number; datasource: string };
mixedEnabled?: boolean; mixedEnabled?: boolean;
}; };
@ -166,7 +166,7 @@ export function setupExplore(options?: SetupOptions): {
}; };
} }
function makeDatasourceSetup({ export function makeDatasourceSetup({
name = 'loki', name = 'loki',
id = 1, id = 1,
uid: uidOverride, uid: uidOverride,
@ -232,10 +232,10 @@ function makeDatasourceSetup({
}; };
} }
export const waitForExplore = (exploreId: ExploreId = ExploreId.left) => { export const waitForExplore = (exploreId = 'left') => {
return waitFor(async () => { return waitFor(async () => {
const container = screen.getAllByTestId('data-testid Explore'); const container = screen.getAllByTestId('data-testid Explore');
return within(container[exploreId === ExploreId.left ? 0 : 1]); return within(container[exploreId === 'left' ? 0 : 1]);
}); });
}; };
@ -243,7 +243,7 @@ export const tearDown = () => {
window.localStorage.clear(); window.localStorage.clear();
}; };
export const withinExplore = (exploreId: ExploreId) => { export const withinExplore = (exploreId: string) => {
const container = screen.getAllByTestId('data-testid Explore'); const container = screen.getAllByTestId('data-testid Explore');
return within(container[exploreId === ExploreId.left ? 0 : 1]); return within(container[exploreId === 'left' ? 0 : 1]);
}; };

View File

@ -1,32 +1,22 @@
import { act, screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { serializeStateToUrlParam } from '@grafana/data'; import { serializeStateToUrlParam } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { makeLogsQueryResponse, makeMetricsQueryResponse } from './helper/query'; import { makeLogsQueryResponse } from './helper/query';
import { setupExplore, tearDown, waitForExplore } from './helper/setup'; import { setupExplore, tearDown, waitForExplore } from './helper/setup';
describe('Explore: handle running/not running query', () => { describe('Explore: handle running/not running query', () => {
afterEach(() => { afterEach(() => {
tearDown(); tearDown();
}); });
it('inits url and renders editor but does not call query on empty url', async () => { it('inits and renders editor but does not call query on empty initial state', async () => {
const { datasources } = setupExplore(); const { datasources } = setupExplore();
await waitForExplore(); await waitForExplore();
// At this point url should be initialised to some defaults
expect(locationService.getSearchObject()).toEqual({
orgId: '1',
left: serializeStateToUrlParam({
datasource: 'loki-uid',
queries: [{ refId: 'A', datasource: { type: 'logs', uid: 'loki-uid' } }],
range: { from: 'now-1h', to: 'now' },
}),
});
expect(datasources.loki.query).not.toBeCalled(); expect(datasources.loki.query).not.toBeCalled();
}); });
it('runs query when url contains query and renders results', async () => { it('runs query when initial state contains query and renders results', async () => {
const urlParams = { const urlParams = {
left: serializeStateToUrlParam({ left: serializeStateToUrlParam({
datasource: 'loki-uid', datasource: 'loki-uid',
@ -46,61 +36,10 @@ describe('Explore: handle running/not running query', () => {
// And that the editor gets the expr from the url // And that the editor gets the expr from the url
await screen.findByText(`loki Editor input: { label="value"}`); await screen.findByText(`loki Editor input: { label="value"}`);
// We did not change the url
expect(locationService.getSearchObject()).toEqual({
orgId: '1',
...urlParams,
});
// We called the data source query method once // We called the data source query method once
expect(datasources.loki.query).toBeCalledTimes(1); expect(datasources.loki.query).toBeCalledTimes(1);
expect(jest.mocked(datasources.loki.query).mock.calls[0][0]).toMatchObject({ expect(jest.mocked(datasources.loki.query).mock.calls[0][0]).toMatchObject({
targets: [{ expr: '{ label="value"}' }], targets: [{ expr: '{ label="value"}' }],
}); });
}); });
describe('handles url change', () => {
const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) };
it('and runs the new query', async () => {
const { datasources } = setupExplore({ urlParams });
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
// Wait for rendering the logs
await screen.findByText(/custom log line/i);
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse('different log'));
act(() => {
locationService.partial({
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="different"}' }]),
});
});
// Editor renders the new query
await screen.findByText(`loki Editor input: { label="different"}`);
// Renders new response
await screen.findByText(/different log/i);
});
it('and runs the new query with different datasource', async () => {
const { datasources } = setupExplore({ urlParams });
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
// Wait for rendering the logs
await screen.findByText(/custom log line/i);
await screen.findByText(`loki Editor input: { label="value"}`);
jest.mocked(datasources.elastic.query).mockReturnValueOnce(makeMetricsQueryResponse());
act(() => {
locationService.partial({
left: JSON.stringify(['now-1h', 'now', 'elastic', { expr: 'other query' }]),
});
});
// Editor renders the new query
await screen.findByText(`elastic Editor input: other query`);
// Renders graph
await screen.findByText(/Graph/i);
});
});
}); });

View File

@ -3,7 +3,6 @@ import { of } from 'rxjs';
import { serializeStateToUrlParam } from '@grafana/data'; import { serializeStateToUrlParam } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { ExploreId } from 'app/types';
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput';
@ -155,26 +154,26 @@ describe('Explore: Query History', () => {
const { datasources } = setupExplore({ urlParams }); const { datasources } = setupExplore({ urlParams });
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse()); jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
await waitForExplore(); await waitForExplore();
await waitForExplore(ExploreId.right); await waitForExplore('right');
// queries in history // queries in history
await openQueryHistory(ExploreId.left); await openQueryHistory('left');
await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}'], ExploreId.left); await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}'], 'left');
await openQueryHistory(ExploreId.right); await openQueryHistory('right');
await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}'], ExploreId.right); await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}'], 'right');
// star one one query // star one one query
await starQueryHistory(1, ExploreId.left); await starQueryHistory(1, 'left');
await assertQueryHistoryIsStarred([false, true], ExploreId.left); await assertQueryHistoryIsStarred([false, true], 'left');
await assertQueryHistoryIsStarred([false, true], ExploreId.right); await assertQueryHistoryIsStarred([false, true], 'right');
expect(reportInteractionMock).toBeCalledWith('grafana_explore_query_history_starred', { expect(reportInteractionMock).toBeCalledWith('grafana_explore_query_history_starred', {
queryHistoryEnabled: false, queryHistoryEnabled: false,
newValue: true, newValue: true,
}); });
await deleteQueryHistory(0, ExploreId.left); await deleteQueryHistory(0, 'left');
await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.left); await assertQueryHistory(['{"expr":"query #1"}'], 'left');
await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.right); await assertQueryHistory(['{"expr":"query #1"}'], 'right');
expect(reportInteractionMock).toBeCalledWith('grafana_explore_query_history_deleted', { expect(reportInteractionMock).toBeCalledWith('grafana_explore_query_history_deleted', {
queryHistoryEnabled: false, queryHistoryEnabled: false,
}); });
@ -193,10 +192,10 @@ describe('Explore: Query History', () => {
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse()); jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
await waitForExplore(); await waitForExplore();
await openQueryHistory(); await openQueryHistory();
await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.left); await assertQueryHistory(['{"expr":"query #1"}'], 'left');
await commentQueryHistory(0, 'test comment'); await commentQueryHistory(0, 'test comment');
await assertQueryHistoryComment(['test comment'], ExploreId.left); await assertQueryHistoryComment(['test comment'], 'left');
}); });
it('updates query history settings', async () => { it('updates query history settings', async () => {

View File

@ -1,6 +1,6 @@
import { DataSourceApi } from '@grafana/data'; import { DataSourceApi } from '@grafana/data';
import { DataQuery } from '@grafana/schema'; import { DataQuery } from '@grafana/schema';
import { ExploreId, ExploreItemState } from 'app/types'; import { ExploreItemState } from 'app/types';
import { updateDatasourceInstanceAction, datasourceReducer } from './datasource'; import { updateDatasourceInstanceAction, datasourceReducer } from './datasource';
import { createEmptyQueryResponse } from './utils'; import { createEmptyQueryResponse } from './utils';
@ -27,7 +27,7 @@ describe('Datasource reducer', () => {
const result = datasourceReducer( const result = datasourceReducer(
initialState, initialState,
updateDatasourceInstanceAction({ exploreId: ExploreId.left, datasourceInstance, history: [] }) updateDatasourceInstanceAction({ exploreId: 'left', datasourceInstance, history: [] })
); );
const expectedState: Partial<ExploreItemState> = { const expectedState: Partial<ExploreItemState> = {

View File

@ -7,7 +7,6 @@ import { DataSourceRef } from '@grafana/schema';
import { RefreshPicker } from '@grafana/ui'; import { RefreshPicker } from '@grafana/ui';
import { stopQueryState } from 'app/core/utils/explore'; import { stopQueryState } from 'app/core/utils/explore';
import { ExploreItemState, ThunkResult } from 'app/types'; import { ExploreItemState, ThunkResult } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { loadSupplementaryQueries } from '../utils/supplementaryQueries'; import { loadSupplementaryQueries } from '../utils/supplementaryQueries';
@ -23,7 +22,7 @@ import { createEmptyQueryResponse, loadAndInitDatasource } from './utils';
* Updates datasource instance before datasource loading has started * Updates datasource instance before datasource loading has started
*/ */
export interface UpdateDatasourceInstancePayload { export interface UpdateDatasourceInstancePayload {
exploreId: ExploreId; exploreId: string;
datasourceInstance: DataSourceApi; datasourceInstance: DataSourceApi;
history: HistoryItem[]; history: HistoryItem[];
} }
@ -39,7 +38,7 @@ export const updateDatasourceInstanceAction = createAction<UpdateDatasourceInsta
* Loads a new datasource identified by the given name. * Loads a new datasource identified by the given name.
*/ */
export function changeDatasource( export function changeDatasource(
exploreId: ExploreId, exploreId: string,
datasource: string | DataSourceRef, datasource: string | DataSourceRef,
options?: { importQueries: boolean } options?: { importQueries: boolean }
): ThunkResult<Promise<void>> { ): ThunkResult<Promise<void>> {

View File

@ -13,7 +13,7 @@ import { DataQuery, DataSourceRef } from '@grafana/schema';
import { getQueryKeys } from 'app/core/utils/explore'; import { getQueryKeys } from 'app/core/utils/explore';
import { getTimeZone } from 'app/features/profile/state/selectors'; import { getTimeZone } from 'app/features/profile/state/selectors';
import { createAsyncThunk, ThunkResult } from 'app/types'; import { createAsyncThunk, ThunkResult } from 'app/types';
import { ExploreId, ExploreItemState } from 'app/types/explore'; import { ExploreItemState } from 'app/types/explore';
import { datasourceReducer } from './datasource'; import { datasourceReducer } from './datasource';
import { historyReducer } from './history'; import { historyReducer } from './history';
@ -32,7 +32,7 @@ import { makeExplorePaneState, loadAndInitDatasource, createEmptyQueryResponse,
* The width will be used to calculate graph intervals (number of datapoints). * The width will be used to calculate graph intervals (number of datapoints).
*/ */
export interface ChangeSizePayload { export interface ChangeSizePayload {
exploreId: ExploreId; exploreId: string;
width: number; width: number;
} }
export const changeSizeAction = createAction<ChangeSizePayload>('explore/changeSize'); export const changeSizeAction = createAction<ChangeSizePayload>('explore/changeSize');
@ -41,12 +41,12 @@ export const changeSizeAction = createAction<ChangeSizePayload>('explore/changeS
* Tracks the state of explore panels that gets synced with the url. * Tracks the state of explore panels that gets synced with the url.
*/ */
interface ChangePanelsState { interface ChangePanelsState {
exploreId: ExploreId; exploreId: string;
panelsState: ExplorePanelsState; panelsState: ExplorePanelsState;
} }
const changePanelsStateAction = createAction<ChangePanelsState>('explore/changePanels'); const changePanelsStateAction = createAction<ChangePanelsState>('explore/changePanels');
export function changePanelState( export function changePanelState(
exploreId: ExploreId, exploreId: string,
panel: PreferredVisualisationType, panel: PreferredVisualisationType,
panelState: ExplorePanelsState[PreferredVisualisationType] panelState: ExplorePanelsState[PreferredVisualisationType]
): ThunkResult<void> { ): ThunkResult<void> {
@ -73,7 +73,7 @@ export function changePanelState(
* Call this only on components for with the Explore state has not been initialized. * Call this only on components for with the Explore state has not been initialized.
*/ */
interface InitializeExplorePayload { interface InitializeExplorePayload {
exploreId: ExploreId; exploreId: string;
queries: DataQuery[]; queries: DataQuery[];
range: TimeRange; range: TimeRange;
history: HistoryItem[]; history: HistoryItem[];
@ -82,7 +82,7 @@ interface InitializeExplorePayload {
const initializeExploreAction = createAction<InitializeExplorePayload>('explore/initializeExploreAction'); const initializeExploreAction = createAction<InitializeExplorePayload>('explore/initializeExploreAction');
export interface SetUrlReplacedPayload { export interface SetUrlReplacedPayload {
exploreId: ExploreId; exploreId: string;
} }
export const setUrlReplacedAction = createAction<SetUrlReplacedPayload>('explore/setUrlReplaced'); export const setUrlReplacedAction = createAction<SetUrlReplacedPayload>('explore/setUrlReplaced');
@ -90,16 +90,17 @@ export const setUrlReplacedAction = createAction<SetUrlReplacedPayload>('explore
* Keep track of the Explore container size, in particular the width. * Keep track of the Explore container size, in particular the width.
* The width will be used to calculate graph intervals (number of datapoints). * The width will be used to calculate graph intervals (number of datapoints).
*/ */
export function changeSize(exploreId: ExploreId, { width }: { width: number }): PayloadAction<ChangeSizePayload> { export function changeSize(exploreId: string, { width }: { width: number }): PayloadAction<ChangeSizePayload> {
return changeSizeAction({ exploreId, width }); return changeSizeAction({ exploreId, width });
} }
interface InitializeExploreOptions { interface InitializeExploreOptions {
exploreId: ExploreId; exploreId: string;
datasource: DataSourceRef | string | undefined; datasource: DataSourceRef | string | undefined;
queries: DataQuery[]; queries: DataQuery[];
range: RawTimeRange; range: RawTimeRange;
panelsState?: ExplorePanelsState; panelsState?: ExplorePanelsState;
position?: number;
} }
/** /**
* Initialize Explore state with state from the URL and the React component. * Initialize Explore state with state from the URL and the React component.

View File

@ -12,7 +12,7 @@ import {
updateRichHistorySettings, updateRichHistorySettings,
updateStarredInRichHistory, updateStarredInRichHistory,
} from 'app/core/utils/richHistory'; } from 'app/core/utils/richHistory';
import { ExploreId, ExploreItemState, ExploreState, RichHistoryQuery, ThunkResult } from 'app/types'; import { ExploreItemState, ExploreState, RichHistoryQuery, ThunkResult } from 'app/types';
import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider'; import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider';
import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes'; import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes';
@ -24,13 +24,14 @@ import {
richHistoryStorageFullAction, richHistoryStorageFullAction,
richHistoryUpdatedAction, richHistoryUpdatedAction,
} from './main'; } from './main';
import { selectPanesEntries } from './selectors';
// //
// Actions and Payloads // Actions and Payloads
// //
export interface HistoryUpdatedPayload { export interface HistoryUpdatedPayload {
exploreId: ExploreId; exploreId: string;
history: HistoryItem[]; history: HistoryItem[];
} }
export const historyUpdatedAction = createAction<HistoryUpdatedPayload>('explore/historyUpdated'); export const historyUpdatedAction = createAction<HistoryUpdatedPayload>('explore/historyUpdated');
@ -66,9 +67,9 @@ const updateRichHistoryState = ({ updatedQuery, deletedId }: SyncHistoryUpdatesO
}; };
}; };
const forEachExplorePane = (state: ExploreState, callback: (item: ExploreItemState, exploreId: ExploreId) => void) => { const forEachExplorePane = (state: ExploreState, callback: (item: ExploreItemState, exploreId: string) => void) => {
Object.entries(state.panes).forEach(([exploreId, item]) => { Object.entries(state.panes).forEach(([exploreId, item]) => {
callback(item!, exploreId as ExploreId); item && callback(item, exploreId);
}); });
}; };
@ -118,18 +119,16 @@ export const deleteHistoryItem = (id: string): ThunkResult<void> => {
}; };
export const deleteRichHistory = (): ThunkResult<void> => { export const deleteRichHistory = (): ThunkResult<void> => {
return async (dispatch) => { return async (dispatch, getState) => {
await deleteAllFromRichHistory(); await deleteAllFromRichHistory();
dispatch( selectPanesEntries(getState()).forEach(([exploreId]) => {
richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId: ExploreId.left }) dispatch(richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId }));
); dispatch(richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId }));
dispatch( });
richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId: ExploreId.right })
);
}; };
}; };
export const loadRichHistory = (exploreId: ExploreId): ThunkResult<void> => { export const loadRichHistory = (exploreId: string): ThunkResult<void> => {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const filters = getState().explore.panes[exploreId]!.richHistorySearchFilters; const filters = getState().explore.panes[exploreId]!.richHistorySearchFilters;
if (filters) { if (filters) {
@ -139,7 +138,7 @@ export const loadRichHistory = (exploreId: ExploreId): ThunkResult<void> => {
}; };
}; };
export const loadMoreRichHistory = (exploreId: ExploreId): ThunkResult<void> => { export const loadMoreRichHistory = (exploreId: string): ThunkResult<void> => {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const currentFilters = getState().explore.panes[exploreId]?.richHistorySearchFilters; const currentFilters = getState().explore.panes[exploreId]?.richHistorySearchFilters;
const currentRichHistory = getState().explore.panes[exploreId]?.richHistory; const currentRichHistory = getState().explore.panes[exploreId]?.richHistory;
@ -155,7 +154,7 @@ export const loadMoreRichHistory = (exploreId: ExploreId): ThunkResult<void> =>
}; };
}; };
export const clearRichHistoryResults = (exploreId: ExploreId): ThunkResult<void> => { export const clearRichHistoryResults = (exploreId: string): ThunkResult<void> => {
return async (dispatch) => { return async (dispatch) => {
dispatch(richHistorySearchFiltersUpdatedAction({ filters: undefined, exploreId })); dispatch(richHistorySearchFiltersUpdatedAction({ filters: undefined, exploreId }));
dispatch(richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId })); dispatch(richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId }));
@ -186,10 +185,7 @@ export const updateHistorySettings = (settings: RichHistorySettings): ThunkResul
/** /**
* Assumed this can be called only when settings and filters are initialised * Assumed this can be called only when settings and filters are initialised
*/ */
export const updateHistorySearchFilters = ( export const updateHistorySearchFilters = (exploreId: string, filters: RichHistorySearchFilters): ThunkResult<void> => {
exploreId: ExploreId,
filters: RichHistorySearchFilters
): ThunkResult<void> => {
return async (dispatch, getState) => { return async (dispatch, getState) => {
await dispatch(richHistorySearchFiltersUpdatedAction({ exploreId, filters: { ...filters } })); await dispatch(richHistorySearchFiltersUpdatedAction({ exploreId, filters: { ...filters } }));
const currentSettings = getState().explore.richHistorySettings!; const currentSettings = getState().explore.richHistorySettings!;

View File

@ -7,7 +7,7 @@ import { PanelModel } from 'app/features/dashboard/state';
import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { MockDataSourceApi } from '../../../../test/mocks/datasource_srv'; import { MockDataSourceApi } from '../../../../test/mocks/datasource_srv';
import { ExploreId, ExploreItemState, ExploreState } from '../../../types'; import { ExploreItemState, ExploreState } from '../../../types';
import { exploreReducer, navigateToExplore, splitClose } from './main'; import { exploreReducer, navigateToExplore, splitClose } from './main';
@ -117,36 +117,6 @@ describe('navigateToExplore', () => {
describe('Explore reducer', () => { describe('Explore reducer', () => {
describe('split view', () => { describe('split view', () => {
describe('split close', () => { describe('split close', () => {
it('should move right pane to left when left is closed', () => {
const leftItemMock = {
containerWidth: 100,
} as unknown as ExploreItemState;
const rightItemMock = {
containerWidth: 200,
} as unknown as ExploreItemState;
const initialState = {
panes: {
left: leftItemMock,
right: rightItemMock,
},
} as unknown as ExploreState;
// closing left item
reducerTester<ExploreState>()
.givenReducer(exploreReducer, initialState)
.whenActionIsDispatched(splitClose(ExploreId.left))
.thenStateShouldEqual({
evenSplitPanes: true,
largerExploreId: undefined,
panes: {
left: rightItemMock,
},
maxedExploreId: undefined,
syncedTimes: false,
} as unknown as ExploreState);
});
it('should reset right pane when it is closed', () => { it('should reset right pane when it is closed', () => {
const leftItemMock = { const leftItemMock = {
containerWidth: 100, containerWidth: 100,
@ -166,7 +136,7 @@ describe('Explore reducer', () => {
// closing left item // closing left item
reducerTester<ExploreState>() reducerTester<ExploreState>()
.givenReducer(exploreReducer, initialState) .givenReducer(exploreReducer, initialState)
.whenActionIsDispatched(splitClose(ExploreId.right)) .whenActionIsDispatched(splitClose('right'))
.thenStateShouldEqual({ .thenStateShouldEqual({
evenSplitPanes: true, evenSplitPanes: true,
largerExploreId: undefined, largerExploreId: undefined,
@ -193,7 +163,7 @@ describe('Explore reducer', () => {
reducerTester<ExploreState>() reducerTester<ExploreState>()
.givenReducer(exploreReducer, initialState) .givenReducer(exploreReducer, initialState)
.whenActionIsDispatched(splitClose(ExploreId.right)) .whenActionIsDispatched(splitClose('right'))
.thenStateShouldEqual({ .thenStateShouldEqual({
evenSplitPanes: true, evenSplitPanes: true,
panes: { panes: {

View File

@ -3,9 +3,9 @@ import { AnyAction } from 'redux';
import { SplitOpenOptions } from '@grafana/data'; import { SplitOpenOptions } from '@grafana/data';
import { DataSourceSrv, locationService } from '@grafana/runtime'; import { DataSourceSrv, locationService } from '@grafana/runtime';
import { GetExploreUrlArguments } from 'app/core/utils/explore'; import { generateExploreId, GetExploreUrlArguments } from 'app/core/utils/explore';
import { PanelModel } from 'app/features/dashboard/state'; import { PanelModel } from 'app/features/dashboard/state';
import { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore'; import { ExploreItemState, ExploreState } from 'app/types/explore';
import { RichHistoryResults } from '../../../core/history/RichHistoryStorage'; import { RichHistoryResults } from '../../../core/history/RichHistoryStorage';
import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes'; import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes';
@ -26,7 +26,7 @@ export interface SyncTimesPayload {
} }
export const syncTimesAction = createAction<SyncTimesPayload>('explore/syncTimes'); export const syncTimesAction = createAction<SyncTimesPayload>('explore/syncTimes');
export const richHistoryUpdatedAction = createAction<{ richHistoryResults: RichHistoryResults; exploreId: ExploreId }>( export const richHistoryUpdatedAction = createAction<{ richHistoryResults: RichHistoryResults; exploreId: string }>(
'explore/richHistoryUpdated' 'explore/richHistoryUpdated'
); );
export const richHistoryStorageFullAction = createAction('explore/richHistoryStorageFullAction'); export const richHistoryStorageFullAction = createAction('explore/richHistoryStorageFullAction');
@ -34,18 +34,18 @@ export const richHistoryLimitExceededAction = createAction('explore/richHistoryL
export const richHistorySettingsUpdatedAction = createAction<RichHistorySettings>('explore/richHistorySettingsUpdated'); export const richHistorySettingsUpdatedAction = createAction<RichHistorySettings>('explore/richHistorySettingsUpdated');
export const richHistorySearchFiltersUpdatedAction = createAction<{ export const richHistorySearchFiltersUpdatedAction = createAction<{
exploreId: ExploreId; exploreId: string;
filters?: RichHistorySearchFilters; filters?: RichHistorySearchFilters;
}>('explore/richHistorySearchFiltersUpdatedAction'); }>('explore/richHistorySearchFiltersUpdatedAction');
export const saveCorrelationsAction = createAction<CorrelationData[]>('explore/saveCorrelationsAction'); export const saveCorrelationsAction = createAction<CorrelationData[]>('explore/saveCorrelationsAction');
export const splitSizeUpdateAction = createAction<{ export const splitSizeUpdateAction = createAction<{
largerExploreId?: ExploreId; largerExploreId?: string;
}>('explore/splitSizeUpdateAction'); }>('explore/splitSizeUpdateAction');
export const maximizePaneAction = createAction<{ export const maximizePaneAction = createAction<{
exploreId?: ExploreId; exploreId?: string;
}>('explore/maximizePaneAction'); }>('explore/maximizePaneAction');
export const evenPaneResizeAction = createAction('explore/evenPaneResizeAction'); export const evenPaneResizeAction = createAction('explore/evenPaneResizeAction');
@ -53,8 +53,7 @@ export const evenPaneResizeAction = createAction('explore/evenPaneResizeAction')
/** /**
* Close the pane with the given id. * Close the pane with the given id.
*/ */
type SplitCloseActionPayload = ExploreId; export const splitClose = createAction<string>('explore/splitClose');
export const splitClose = createAction<SplitCloseActionPayload>('explore/splitClose');
export interface SetPaneStateActionPayload { export interface SetPaneStateActionPayload {
[itemId: string]: Partial<ExploreItemState>; [itemId: string]: Partial<ExploreItemState>;
@ -64,27 +63,31 @@ export const setPaneState = createAction<SetPaneStateActionPayload>('explore/set
export const clearPanes = createAction('explore/clearPanes'); export const clearPanes = createAction('explore/clearPanes');
/** /**
* Opens a new split pane. It either copies existing state of the left pane * Opens a new split pane. It either copies existing state of an already present pane
* or uses values from options arg. * or uses values from options arg.
* *
* TODO: this can be improved by better inferring fallback values. * TODO: this can be improved by better inferring fallback values.
*/ */
export const splitOpen = createAsyncThunk( export const splitOpen = createAsyncThunk(
'explore/splitOpen', 'explore/splitOpen',
async (options: SplitOpenOptions | undefined, { getState, dispatch }) => { async (options: SplitOpenOptions | undefined, { getState, dispatch, requestId }) => {
const leftState = getState().explore.panes.left; // we currently support showing only 2 panes in explore, so if this action is dispatched we know it has been dispatched from the "first" pane.
const originState = Object.values(getState().explore.panes)[0];
const queries = options?.queries ?? (options?.query ? [options?.query] : leftState?.queries || []); const queries = options?.queries ?? (options?.query ? [options?.query] : originState?.queries || []);
await dispatch( await dispatch(
initializeExplore({ initializeExplore({
exploreId: ExploreId.right, exploreId: requestId,
datasource: options?.datasourceUid || leftState?.datasourceInstance?.getRef(), datasource: options?.datasourceUid || originState?.datasourceInstance?.getRef(),
queries: withUniqueRefIds(queries), queries: withUniqueRefIds(queries),
range: options?.range || leftState?.range.raw || DEFAULT_RANGE, range: options?.range || originState?.range.raw || DEFAULT_RANGE,
panelsState: options?.panelsState || leftState?.panelsState, panelsState: options?.panelsState || originState?.panelsState,
}) })
); );
},
{
idGenerator: generateExploreId,
} }
); );
@ -138,9 +141,8 @@ export const initialExploreState: ExploreState = {
*/ */
export const exploreReducer = (state = initialExploreState, action: AnyAction): ExploreState => { export const exploreReducer = (state = initialExploreState, action: AnyAction): ExploreState => {
if (splitClose.match(action)) { if (splitClose.match(action)) {
const panes = { const { [action.payload]: _, ...panes } = { ...state.panes };
left: action.payload === ExploreId.left ? state.panes.right : state.panes.left,
};
return { return {
...state, ...state,
panes, panes,
@ -218,18 +220,23 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction):
...state, ...state,
panes: { panes: {
...state.panes, ...state.panes,
right: initialExploreItemState, [action.meta.requestId]: initialExploreItemState,
}, },
}; };
} }
if (initializeExplore.pending.match(action)) { if (initializeExplore.pending.match(action)) {
const initialPanes = Object.entries(state.panes);
const before = initialPanes.slice(0, action.meta.arg.position);
const after = initialPanes.slice(before.length);
const panes = [...before, [action.meta.arg.exploreId, initialExploreItemState] as const, ...after].reduce(
(acc, [id, pane]) => ({ ...acc, [id]: pane }),
{}
);
return { return {
...state, ...state,
panes: { panes,
...state.panes,
[action.meta.arg.exploreId]: initialExploreItemState,
},
}; };
} }
@ -240,17 +247,15 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction):
}; };
} }
const exploreId: ExploreId | undefined = action.payload?.exploreId; const exploreId: string | undefined = action.payload?.exploreId;
if (typeof exploreId === 'string') { if (typeof exploreId === 'string') {
return { return {
...state, ...state,
panes: Object.entries(state.panes).reduce<ExploreState['panes']>((acc, [id, pane]) => { panes: Object.entries(state.panes).reduce((acc, [id, pane]) => {
if (id === exploreId) { return {
acc[id] = paneReducer(pane, action); ...acc,
} else { [id]: id === exploreId ? paneReducer(pane, action) : pane,
acc[id as ExploreId] = pane; };
}
return acc;
}, {}), }, {}),
}; };
} }

View File

@ -16,7 +16,7 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { DataQuery, DataSourceRef } from '@grafana/schema'; import { DataQuery, DataSourceRef } from '@grafana/schema';
import { createAsyncThunk, ExploreId, ExploreItemState, StoreState, ThunkDispatch } from 'app/types'; import { createAsyncThunk, ExploreItemState, StoreState, ThunkDispatch } from 'app/types';
import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { configureStore } from '../../../store/configureStore'; import { configureStore } from '../../../store/configureStore';
@ -51,7 +51,7 @@ import { makeExplorePaneState } from './utils';
const { testRange, defaultInitialState } = createDefaultInitialState(); const { testRange, defaultInitialState } = createDefaultInitialState();
const exploreId = ExploreId.left; const exploreId = 'left';
const datasources: DataSourceApi[] = [ const datasources: DataSourceApi[] = [
{ {
name: 'testDs', name: 'testDs',
@ -158,7 +158,7 @@ describe('runQueries', () => {
const { dispatch, getState } = setupTests(); const { dispatch, getState } = setupTests();
setupQueryResponse(getState()); setupQueryResponse(getState());
await dispatch(saveCorrelationsAction([])); await dispatch(saveCorrelationsAction([]));
await dispatch(runQueries({ exploreId: ExploreId.left })); await dispatch(runQueries({ exploreId: 'left' }));
expect(getState().explore.panes.left!.showMetrics).toBeTruthy(); expect(getState().explore.panes.left!.showMetrics).toBeTruthy();
expect(getState().explore.panes.left!.graphResult).toBeDefined(); expect(getState().explore.panes.left!.graphResult).toBeDefined();
}); });
@ -167,7 +167,7 @@ describe('runQueries', () => {
const { dispatch, getState } = setupTests(); const { dispatch, getState } = setupTests();
setupQueryResponse(getState()); setupQueryResponse(getState());
dispatch(saveCorrelationsAction([])); dispatch(saveCorrelationsAction([]));
dispatch(runQueries({ exploreId: ExploreId.left })); dispatch(runQueries({ exploreId: 'left' }));
const state = getState().explore.panes.left!; const state = getState().explore.panes.left!;
expect(state.queryResponse.request?.requestId).toBe('explore_left'); expect(state.queryResponse.request?.requestId).toBe('explore_left');
@ -187,7 +187,7 @@ describe('runQueries', () => {
const leftDatasourceInstance = assertIsDefined(getState().explore.panes.left!.datasourceInstance); const leftDatasourceInstance = assertIsDefined(getState().explore.panes.left!.datasourceInstance);
jest.mocked(leftDatasourceInstance.query).mockReturnValueOnce(EMPTY); jest.mocked(leftDatasourceInstance.query).mockReturnValueOnce(EMPTY);
await dispatch(saveCorrelationsAction([])); await dispatch(saveCorrelationsAction([]));
await dispatch(runQueries({ exploreId: ExploreId.left })); await dispatch(runQueries({ exploreId: 'left' }));
await new Promise((resolve) => setTimeout(() => resolve(''), 500)); await new Promise((resolve) => setTimeout(() => resolve(''), 500));
expect(getState().explore.panes.left!.queryResponse.state).toBe(LoadingState.Done); expect(getState().explore.panes.left!.queryResponse.state).toBe(LoadingState.Done);
}); });
@ -195,7 +195,7 @@ describe('runQueries', () => {
it('shows results only after correlations are loaded', async () => { it('shows results only after correlations are loaded', async () => {
const { dispatch, getState } = setupTests(); const { dispatch, getState } = setupTests();
setupQueryResponse(getState()); setupQueryResponse(getState());
await dispatch(runQueries({ exploreId: ExploreId.left })); await dispatch(runQueries({ exploreId: 'left' }));
expect(getState().explore.panes.left!.graphResult).not.toBeDefined(); expect(getState().explore.panes.left!.graphResult).not.toBeDefined();
await dispatch(saveCorrelationsAction([])); await dispatch(saveCorrelationsAction([]));
expect(getState().explore.panes.left!.graphResult).toBeDefined(); expect(getState().explore.panes.left!.graphResult).toBeDefined();
@ -206,7 +206,7 @@ describe('running queries', () => {
it('should cancel running query when cancelQueries is dispatched', async () => { it('should cancel running query when cancelQueries is dispatched', async () => {
const unsubscribable = interval(1000); const unsubscribable = interval(1000);
unsubscribable.subscribe(); unsubscribable.subscribe();
const exploreId = ExploreId.left; const exploreId = 'left';
const initialState = { const initialState = {
explore: { explore: {
panes: { panes: {
@ -274,13 +274,13 @@ describe('changeQueries', () => {
await dispatch( await dispatch(
changeQueries({ changeQueries({
queries: [{ refId: 'A', datasource: datasources[1].getRef() }], queries: [{ refId: 'A', datasource: datasources[1].getRef() }],
exploreId: ExploreId.left, exploreId: 'left',
}) })
); );
expect(actions.changeQueriesAction).not.toHaveBeenCalled(); expect(actions.changeQueriesAction).not.toHaveBeenCalled();
expect(actions.importQueries).toHaveBeenCalledWith( expect(actions.importQueries).toHaveBeenCalledWith(
ExploreId.left, 'left',
originalQueries, originalQueries,
datasources[0], datasources[0],
datasources[1], datasources[1],
@ -308,7 +308,7 @@ describe('changeQueries', () => {
await dispatch( await dispatch(
changeQueries({ changeQueries({
queries: [{ refId: 'A', datasource: datasources[0].getRef(), queryType: 'someValue' }], queries: [{ refId: 'A', datasource: datasources[0].getRef(), queryType: 'someValue' }],
exploreId: ExploreId.left, exploreId: 'left',
}) })
); );
@ -337,7 +337,7 @@ describe('changeQueries', () => {
await dispatch( await dispatch(
changeQueries({ changeQueries({
queries: [{ refId: 'A', datasource: datasources[1].getRef() }], queries: [{ refId: 'A', datasource: datasources[1].getRef() }],
exploreId: ExploreId.left, exploreId: 'left',
}) })
); );
@ -362,7 +362,7 @@ describe('changeQueries', () => {
await dispatch( await dispatch(
changeQueries({ changeQueries({
queries: [{ refId: 'A', datasource: datasources[0].getRef(), queryType: 'someValue' }], queries: [{ refId: 'A', datasource: datasources[0].getRef(), queryType: 'someValue' }],
exploreId: ExploreId.left, exploreId: 'left',
}) })
); );
@ -400,7 +400,7 @@ describe('changeQueries', () => {
await dispatch( await dispatch(
changeQueries({ changeQueries({
queries: [originalQueries[0]], queries: [originalQueries[0]],
exploreId: ExploreId.left, exploreId: 'left',
}) })
); );
@ -425,7 +425,7 @@ describe('importing queries', () => {
await dispatch( await dispatch(
importQueries( importQueries(
ExploreId.left, 'left',
[ [
{ datasource: { type: 'postgresql', uid: 'ds1' }, refId: 'refId_A' }, { datasource: { type: 'postgresql', uid: 'ds1' }, refId: 'refId_A' },
{ datasource: { type: 'postgresql', uid: 'ds1' }, refId: 'refId_B' }, { datasource: { type: 'postgresql', uid: 'ds1' }, refId: 'refId_B' },
@ -606,7 +606,7 @@ describe('reducer', () => {
reducerTester<ExploreItemState>() reducerTester<ExploreItemState>()
.givenReducer(queryReducer, initialState) .givenReducer(queryReducer, initialState)
.whenActionIsDispatched(scanStartAction({ exploreId: ExploreId.left })) .whenActionIsDispatched(scanStartAction({ exploreId: 'left' }))
.thenStateShouldEqual({ .thenStateShouldEqual({
...initialState, ...initialState,
scanning: true, scanning: true,
@ -621,7 +621,7 @@ describe('reducer', () => {
reducerTester<ExploreItemState>() reducerTester<ExploreItemState>()
.givenReducer(queryReducer, initialState) .givenReducer(queryReducer, initialState)
.whenActionIsDispatched(scanStopAction({ exploreId: ExploreId.left })) .whenActionIsDispatched(scanStopAction({ exploreId: 'left' }))
.thenStateShouldEqual({ .thenStateShouldEqual({
...initialState, ...initialState,
scanning: false, scanning: false,
@ -638,7 +638,7 @@ describe('reducer', () => {
} as unknown as ExploreItemState) } as unknown as ExploreItemState)
.whenActionIsDispatched( .whenActionIsDispatched(
addQueryRowAction({ addQueryRowAction({
exploreId: ExploreId.left, exploreId: 'left',
query: { refId: 'A', key: 'mockKey' }, query: { refId: 'A', key: 'mockKey' },
index: 0, index: 0,
}) })
@ -655,7 +655,7 @@ describe('reducer', () => {
} as unknown as ExploreItemState) } as unknown as ExploreItemState)
.whenActionIsDispatched( .whenActionIsDispatched(
addQueryRowAction({ addQueryRowAction({
exploreId: ExploreId.left, exploreId: 'left',
query: { refId: 'B', key: 'mockKey', datasource: { type: 'loki' } }, query: { refId: 'B', key: 'mockKey', datasource: { type: 'loki' } },
index: 0, index: 0,
}) })
@ -688,7 +688,7 @@ describe('reducer', () => {
}, },
} as unknown as Partial<StoreState>); } as unknown as Partial<StoreState>);
await dispatch(addResultsToCache(ExploreId.left)); await dispatch(addResultsToCache('left'));
expect(getState().explore.panes.left!.cache).toEqual([ expect(getState().explore.panes.left!.cache).toEqual([
{ key: 'from=1621348027000&to=1621348050000', value: { series: [{ name: 'test name' }], state: 'Done' } }, { key: 'from=1621348027000&to=1621348050000', value: { series: [{ name: 'test name' }], state: 'Done' } },
@ -709,7 +709,7 @@ describe('reducer', () => {
}, },
} as unknown as Partial<StoreState>); } as unknown as Partial<StoreState>);
await dispatch(addResultsToCache(ExploreId.left)); await dispatch(addResultsToCache('left'));
expect(getState().explore.panes.left!.cache).toEqual([]); expect(getState().explore.panes.left!.cache).toEqual([]);
}); });
@ -737,7 +737,7 @@ describe('reducer', () => {
}, },
} as unknown as Partial<StoreState>); } as unknown as Partial<StoreState>);
await dispatch(addResultsToCache(ExploreId.left)); await dispatch(addResultsToCache('left'));
expect(getState().explore.panes.left!.cache).toHaveLength(1); expect(getState().explore.panes.left!.cache).toHaveLength(1);
expect(getState().explore.panes.left!.cache).toEqual([ expect(getState().explore.panes.left!.cache).toEqual([
@ -763,7 +763,7 @@ describe('reducer', () => {
}, },
} as unknown as Partial<StoreState>); } as unknown as Partial<StoreState>);
await dispatch(clearCache(ExploreId.left)); await dispatch(clearCache('left'));
expect(getState().explore.panes.left!.cache).toEqual([]); expect(getState().explore.panes.left!.cache).toEqual([]);
}); });
@ -822,7 +822,7 @@ describe('reducer', () => {
}); });
it('should cancel any unfinished supplementary queries when a new query is run', async () => { it('should cancel any unfinished supplementary queries when a new query is run', async () => {
dispatch(runQueries({ exploreId: ExploreId.left })); dispatch(runQueries({ exploreId: 'left' }));
// first query is run automatically // first query is run automatically
// loading in progress - subscriptions for both supplementary queries are created, not cleaned up yet // loading in progress - subscriptions for both supplementary queries are created, not cleaned up yet
expect(unsubscribes).toHaveLength(2); expect(unsubscribes).toHaveLength(2);
@ -830,7 +830,7 @@ describe('reducer', () => {
expect(unsubscribes[1]).not.toBeCalled(); expect(unsubscribes[1]).not.toBeCalled();
setupQueryResponse(getState()); setupQueryResponse(getState());
dispatch(runQueries({ exploreId: ExploreId.left })); dispatch(runQueries({ exploreId: 'left' }));
// a new query is run while supplementary queries are not resolve yet... // a new query is run while supplementary queries are not resolve yet...
expect(unsubscribes[0]).toBeCalled(); expect(unsubscribes[0]).toBeCalled();
expect(unsubscribes[1]).toBeCalled(); expect(unsubscribes[1]).toBeCalled();
@ -841,12 +841,12 @@ describe('reducer', () => {
}); });
it('should cancel all supported supplementary queries when the main query is canceled', () => { it('should cancel all supported supplementary queries when the main query is canceled', () => {
dispatch(runQueries({ exploreId: ExploreId.left })); dispatch(runQueries({ exploreId: 'left' }));
expect(unsubscribes).toHaveLength(2); expect(unsubscribes).toHaveLength(2);
expect(unsubscribes[0]).not.toBeCalled(); expect(unsubscribes[0]).not.toBeCalled();
expect(unsubscribes[1]).not.toBeCalled(); expect(unsubscribes[1]).not.toBeCalled();
dispatch(cancelQueries(ExploreId.left)); dispatch(cancelQueries('left'));
expect(unsubscribes).toHaveLength(2); expect(unsubscribes).toHaveLength(2);
expect(unsubscribes[0]).toBeCalled(); expect(unsubscribes[0]).toBeCalled();
expect(unsubscribes[1]).toBeCalled(); expect(unsubscribes[1]).toBeCalled();
@ -858,7 +858,7 @@ describe('reducer', () => {
}); });
it('should load supplementary queries after running the query', () => { it('should load supplementary queries after running the query', () => {
dispatch(runQueries({ exploreId: ExploreId.left })); dispatch(runQueries({ exploreId: 'left' }));
expect(unsubscribes).toHaveLength(2); expect(unsubscribes).toHaveLength(2);
}); });
@ -866,7 +866,7 @@ describe('reducer', () => {
mockDataProvider = () => { mockDataProvider = () => {
return of({ state: LoadingState.Loading, error: undefined, data: [] }); return of({ state: LoadingState.Loading, error: undefined, data: [] });
}; };
dispatch(runQueries({ exploreId: ExploreId.left })); dispatch(runQueries({ exploreId: 'left' }));
for (const type of supplementaryQueryTypes) { for (const type of supplementaryQueryTypes) {
expect(getState().explore.panes.left!.supplementaryQueries[type].data).toBeDefined(); expect(getState().explore.panes.left!.supplementaryQueries[type].data).toBeDefined();
@ -879,7 +879,7 @@ describe('reducer', () => {
expect(getState().explore.panes.left!.supplementaryQueries[type].dataProvider).toBeDefined(); expect(getState().explore.panes.left!.supplementaryQueries[type].dataProvider).toBeDefined();
} }
dispatch(cancelQueries(ExploreId.left)); dispatch(cancelQueries('left'));
for (const type of supplementaryQueryTypes) { for (const type of supplementaryQueryTypes) {
expect(getState().explore.panes.left!.supplementaryQueries[type].data).toBeUndefined(); expect(getState().explore.panes.left!.supplementaryQueries[type].data).toBeUndefined();
expect(getState().explore.panes.left!.supplementaryQueries[type].data).toBeUndefined(); expect(getState().explore.panes.left!.supplementaryQueries[type].data).toBeUndefined();
@ -893,7 +893,7 @@ describe('reducer', () => {
{ state: LoadingState.Done, error: undefined, data: [{}] } { state: LoadingState.Done, error: undefined, data: [{}] }
); );
}; };
dispatch(runQueries({ exploreId: ExploreId.left })); dispatch(runQueries({ exploreId: 'left' }));
for (const types of supplementaryQueryTypes) { for (const types of supplementaryQueryTypes) {
expect(getState().explore.panes.left!.supplementaryQueries[types].data).toBeDefined(); expect(getState().explore.panes.left!.supplementaryQueries[types].data).toBeDefined();
@ -901,7 +901,7 @@ describe('reducer', () => {
expect(getState().explore.panes.left!.supplementaryQueries[types].dataProvider).toBeDefined(); expect(getState().explore.panes.left!.supplementaryQueries[types].dataProvider).toBeDefined();
} }
dispatch(cancelQueries(ExploreId.left)); dispatch(cancelQueries('left'));
for (const types of supplementaryQueryTypes) { for (const types of supplementaryQueryTypes) {
expect(getState().explore.panes.left!.supplementaryQueries[types].data).toBeDefined(); expect(getState().explore.panes.left!.supplementaryQueries[types].data).toBeDefined();
@ -915,14 +915,14 @@ describe('reducer', () => {
return of({ state: LoadingState.Done, error: undefined, data: [{}] }); return of({ state: LoadingState.Done, error: undefined, data: [{}] });
}; };
// turn logs volume off (but keep logs sample on) // turn logs volume off (but keep logs sample on)
dispatch(setSupplementaryQueryEnabled(ExploreId.left, false, SupplementaryQueryType.LogsVolume)); dispatch(setSupplementaryQueryEnabled('left', false, SupplementaryQueryType.LogsVolume));
expect(getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].enabled).toBe( expect(getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].enabled).toBe(
false false
); );
expect(getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsSample].enabled).toBe(true); expect(getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsSample].enabled).toBe(true);
// verify that if we run a query, it will: 1) not do logs volume, 2) do logs sample 3) provider will still be set for both // verify that if we run a query, it will: 1) not do logs volume, 2) do logs sample 3) provider will still be set for both
dispatch(runQueries({ exploreId: ExploreId.left })); dispatch(runQueries({ exploreId: 'left' }));
expect( expect(
getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].data getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].data
@ -945,11 +945,11 @@ describe('reducer', () => {
it('load data of supplementary query that gets enabled', async () => { it('load data of supplementary query that gets enabled', async () => {
// first we start with both supplementary queries disabled // first we start with both supplementary queries disabled
dispatch(setSupplementaryQueryEnabled(ExploreId.left, false, SupplementaryQueryType.LogsVolume)); dispatch(setSupplementaryQueryEnabled('left', false, SupplementaryQueryType.LogsVolume));
dispatch(setSupplementaryQueryEnabled(ExploreId.left, false, SupplementaryQueryType.LogsSample)); dispatch(setSupplementaryQueryEnabled('left', false, SupplementaryQueryType.LogsSample));
// runQueries sets up providers, but does not run queries // runQueries sets up providers, but does not run queries
dispatch(runQueries({ exploreId: ExploreId.left })); dispatch(runQueries({ exploreId: 'left' }));
expect( expect(
getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].dataProvider getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].dataProvider
).toBeDefined(); ).toBeDefined();
@ -958,7 +958,7 @@ describe('reducer', () => {
).toBeDefined(); ).toBeDefined();
// we turn 1 supplementary query (logs volume) on // we turn 1 supplementary query (logs volume) on
dispatch(setSupplementaryQueryEnabled(ExploreId.left, true, SupplementaryQueryType.LogsVolume)); dispatch(setSupplementaryQueryEnabled('left', true, SupplementaryQueryType.LogsVolume));
// verify it was turned on // verify it was turned on
expect(getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].enabled).toBe(true); expect(getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].enabled).toBe(true);
@ -984,8 +984,8 @@ describe('reducer', () => {
...defaultInitialState, ...defaultInitialState,
explore: { explore: {
panes: { panes: {
[ExploreId.left]: { ['left']: {
...defaultInitialState.explore.panes[ExploreId.left], ...defaultInitialState.explore.panes['left'],
queryResponse: { queryResponse: {
state: LoadingState.Streaming, state: LoadingState.Streaming,
}, },
@ -997,12 +997,12 @@ describe('reducer', () => {
}, },
}, },
} as unknown as Partial<StoreState>); } as unknown as Partial<StoreState>);
expect(getState().explore.panes[ExploreId.left]?.logsResult?.rows.length).toBe(logRows.length); expect(getState().explore.panes['left']?.logsResult?.rows.length).toBe(logRows.length);
await dispatch(clearLogs({ exploreId: ExploreId.left })); await dispatch(clearLogs({ exploreId: 'left' }));
expect(getState().explore.panes[ExploreId.left]?.logsResult?.rows.length).toBe(0); expect(getState().explore.panes['left']?.logsResult?.rows.length).toBe(0);
expect(getState().explore.panes[ExploreId.left]?.clearedAtIndex).toBe(logRows.length - 1); expect(getState().explore.panes['left']?.clearedAtIndex).toBe(logRows.length - 1);
}); });
it('should filter new log rows', async () => { it('should filter new log rows', async () => {
@ -1014,8 +1014,8 @@ describe('reducer', () => {
...defaultInitialState, ...defaultInitialState,
explore: { explore: {
panes: { panes: {
[ExploreId.left]: { ['left']: {
...defaultInitialState.explore.panes[ExploreId.left], ...defaultInitialState.explore.panes['left'],
isLive: true, isLive: true,
queryResponse: { queryResponse: {
state: LoadingState.Streaming, state: LoadingState.Streaming,
@ -1029,12 +1029,12 @@ describe('reducer', () => {
}, },
} as unknown as Partial<StoreState>); } as unknown as Partial<StoreState>);
expect(getState().explore.panes[ExploreId.left]?.logsResult?.rows.length).toBe(oldLogRows.length); expect(getState().explore.panes['left']?.logsResult?.rows.length).toBe(oldLogRows.length);
await dispatch(clearLogs({ exploreId: ExploreId.left })); await dispatch(clearLogs({ exploreId: 'left' }));
await dispatch( await dispatch(
queryStreamUpdatedAction({ queryStreamUpdatedAction({
exploreId: ExploreId.left, exploreId: 'left',
response: { response: {
request: true, request: true,
traceFrames: [], traceFrames: [],
@ -1049,8 +1049,8 @@ describe('reducer', () => {
} as unknown as QueryEndedPayload) } as unknown as QueryEndedPayload)
); );
expect(getState().explore.panes[ExploreId.left]?.logsResult?.rows.length).toBe(newLogRows.length); expect(getState().explore.panes['left']?.logsResult?.rows.length).toBe(newLogRows.length);
expect(getState().explore.panes[ExploreId.left]?.clearedAtIndex).toBe(oldLogRows.length - 1); expect(getState().explore.panes['left']?.clearedAtIndex).toBe(oldLogRows.length - 1);
}); });
}); });
}); });

View File

@ -47,7 +47,7 @@ import {
ThunkDispatch, ThunkDispatch,
ThunkResult, ThunkResult,
} from 'app/types'; } from 'app/types';
import { ExploreId, ExploreState, QueryOptions, SupplementaryQueries } from 'app/types/explore'; import { ExploreState, QueryOptions, SupplementaryQueries } from 'app/types/explore';
import { notifyApp } from '../../../core/actions'; import { notifyApp } from '../../../core/actions';
import { createErrorNotification } from '../../../core/copy/appNotification'; import { createErrorNotification } from '../../../core/copy/appNotification';
@ -66,7 +66,7 @@ import { createCacheKey, filterLogRowsByIndex, getResultsFromCache } from './uti
/** /**
* Derives from explore state if a given Explore pane is waiting for more data to be received * Derives from explore state if a given Explore pane is waiting for more data to be received
*/ */
export const selectIsWaitingForData = (exploreId: ExploreId) => { export const selectIsWaitingForData = (exploreId: string) => {
return (state: StoreState) => { return (state: StoreState) => {
const panelState = state.explore.panes[exploreId]; const panelState = state.explore.panes[exploreId];
if (!panelState) { if (!panelState) {
@ -83,7 +83,7 @@ export const selectIsWaitingForData = (exploreId: ExploreId) => {
* Adds a query row after the row with the given index. * Adds a query row after the row with the given index.
*/ */
export interface AddQueryRowPayload { export interface AddQueryRowPayload {
exploreId: ExploreId; exploreId: string;
index: number; index: number;
query: DataQuery; query: DataQuery;
} }
@ -94,7 +94,7 @@ export const addQueryRowAction = createAction<AddQueryRowPayload>('explore/addQu
* If `override` is reset the query modifications and run the queries. Use this to set queries via a link. * If `override` is reset the query modifications and run the queries. Use this to set queries via a link.
*/ */
export interface ChangeQueriesPayload { export interface ChangeQueriesPayload {
exploreId: ExploreId; exploreId: string;
queries: DataQuery[]; queries: DataQuery[];
} }
export const changeQueriesAction = createAction<ChangeQueriesPayload>('explore/changeQueries'); export const changeQueriesAction = createAction<ChangeQueriesPayload>('explore/changeQueries');
@ -103,18 +103,18 @@ export const changeQueriesAction = createAction<ChangeQueriesPayload>('explore/c
* Cancel running queries. * Cancel running queries.
*/ */
export interface CancelQueriesPayload { export interface CancelQueriesPayload {
exploreId: ExploreId; exploreId: string;
} }
export const cancelQueriesAction = createAction<CancelQueriesPayload>('explore/cancelQueries'); export const cancelQueriesAction = createAction<CancelQueriesPayload>('explore/cancelQueries');
export interface QueriesImportedPayload { export interface QueriesImportedPayload {
exploreId: ExploreId; exploreId: string;
queries: DataQuery[]; queries: DataQuery[];
} }
export const queriesImportedAction = createAction<QueriesImportedPayload>('explore/queriesImported'); export const queriesImportedAction = createAction<QueriesImportedPayload>('explore/queriesImported');
export interface QueryStoreSubscriptionPayload { export interface QueryStoreSubscriptionPayload {
exploreId: ExploreId; exploreId: string;
querySubscription: Unsubscribable; querySubscription: Unsubscribable;
} }
@ -123,19 +123,19 @@ export const queryStoreSubscriptionAction = createAction<QueryStoreSubscriptionP
); );
const setSupplementaryQueryEnabledAction = createAction<{ const setSupplementaryQueryEnabledAction = createAction<{
exploreId: ExploreId; exploreId: string;
type: SupplementaryQueryType; type: SupplementaryQueryType;
enabled: boolean; enabled: boolean;
}>('explore/setSupplementaryQueryEnabledAction'); }>('explore/setSupplementaryQueryEnabledAction');
export interface StoreSupplementaryQueryDataProvider { export interface StoreSupplementaryQueryDataProvider {
exploreId: ExploreId; exploreId: string;
dataProvider?: Observable<DataQueryResponse>; dataProvider?: Observable<DataQueryResponse>;
type: SupplementaryQueryType; type: SupplementaryQueryType;
} }
export interface CleanSupplementaryQueryDataProvider { export interface CleanSupplementaryQueryDataProvider {
exploreId: ExploreId; exploreId: string;
type: SupplementaryQueryType; type: SupplementaryQueryType;
} }
@ -150,12 +150,12 @@ export const cleanSupplementaryQueryDataProviderAction = createAction<CleanSuppl
'explore/cleanSupplementaryQueryDataProviderAction' 'explore/cleanSupplementaryQueryDataProviderAction'
); );
export const cleanSupplementaryQueryAction = createAction<{ exploreId: ExploreId; type: SupplementaryQueryType }>( export const cleanSupplementaryQueryAction = createAction<{ exploreId: string; type: SupplementaryQueryType }>(
'explore/cleanSupplementaryQueryAction' 'explore/cleanSupplementaryQueryAction'
); );
export interface StoreSupplementaryQueryDataSubscriptionPayload { export interface StoreSupplementaryQueryDataSubscriptionPayload {
exploreId: ExploreId; exploreId: string;
dataSubscription?: SubscriptionLike; dataSubscription?: SubscriptionLike;
type: SupplementaryQueryType; type: SupplementaryQueryType;
} }
@ -171,13 +171,13 @@ const storeSupplementaryQueryDataSubscriptionAction = createAction<StoreSuppleme
* Stores data returned by the provider. Used internally by loadSupplementaryQueryData(). * Stores data returned by the provider. Used internally by loadSupplementaryQueryData().
*/ */
const updateSupplementaryQueryDataAction = createAction<{ const updateSupplementaryQueryDataAction = createAction<{
exploreId: ExploreId; exploreId: string;
type: SupplementaryQueryType; type: SupplementaryQueryType;
data: DataQueryResponse; data: DataQueryResponse;
}>('explore/updateSupplementaryQueryDataAction'); }>('explore/updateSupplementaryQueryDataAction');
export interface QueryEndedPayload { export interface QueryEndedPayload {
exploreId: ExploreId; exploreId: string;
response: ExplorePanelData; response: ExplorePanelData;
} }
export const queryStreamUpdatedAction = createAction<QueryEndedPayload>('explore/queryStreamUpdated'); export const queryStreamUpdatedAction = createAction<QueryEndedPayload>('explore/queryStreamUpdated');
@ -186,25 +186,25 @@ export const queryStreamUpdatedAction = createAction<QueryEndedPayload>('explore
* Reset queries to the given queries. Any modifications will be discarded. * Reset queries to the given queries. Any modifications will be discarded.
*/ */
export interface SetQueriesPayload { export interface SetQueriesPayload {
exploreId: ExploreId; exploreId: string;
queries: DataQuery[]; queries: DataQuery[];
} }
export const setQueriesAction = createAction<SetQueriesPayload>('explore/setQueries'); export const setQueriesAction = createAction<SetQueriesPayload>('explore/setQueries');
export interface ChangeLoadingStatePayload { export interface ChangeLoadingStatePayload {
exploreId: ExploreId; exploreId: string;
loadingState: LoadingState; loadingState: LoadingState;
} }
export const changeLoadingStateAction = createAction<ChangeLoadingStatePayload>('changeLoadingState'); export const changeLoadingStateAction = createAction<ChangeLoadingStatePayload>('changeLoadingState');
export interface SetPausedStatePayload { export interface SetPausedStatePayload {
exploreId: ExploreId; exploreId: string;
isPaused: boolean; isPaused: boolean;
} }
export const setPausedStateAction = createAction<SetPausedStatePayload>('explore/setPausedState'); export const setPausedStateAction = createAction<SetPausedStatePayload>('explore/setPausedState');
export interface ClearLogsPayload { export interface ClearLogsPayload {
exploreId: ExploreId; exploreId: string;
} }
export const clearLogs = createAction<ClearLogsPayload>('explore/clearLogs'); export const clearLogs = createAction<ClearLogsPayload>('explore/clearLogs');
/** /**
@ -213,7 +213,7 @@ export const clearLogs = createAction<ClearLogsPayload>('explore/clearLogs');
* @param scanner Function that a) returns a new time range and b) triggers a query run for the new range * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range
*/ */
export interface ScanStartPayload { export interface ScanStartPayload {
exploreId: ExploreId; exploreId: string;
} }
export const scanStartAction = createAction<ScanStartPayload>('explore/scanStart'); export const scanStartAction = createAction<ScanStartPayload>('explore/scanStart');
@ -221,7 +221,7 @@ export const scanStartAction = createAction<ScanStartPayload>('explore/scanStart
* Stop any scanning for more results. * Stop any scanning for more results.
*/ */
export interface ScanStopPayload { export interface ScanStopPayload {
exploreId: ExploreId; exploreId: string;
} }
export const scanStopAction = createAction<ScanStopPayload>('explore/scanStop'); export const scanStopAction = createAction<ScanStopPayload>('explore/scanStop');
@ -230,7 +230,7 @@ export const scanStopAction = createAction<ScanStopPayload>('explore/scanStop');
* This is currently used to cache last 5 query results for log queries run from logs navigation (pagination). * This is currently used to cache last 5 query results for log queries run from logs navigation (pagination).
*/ */
export interface AddResultsToCachePayload { export interface AddResultsToCachePayload {
exploreId: ExploreId; exploreId: string;
cacheKey: string; cacheKey: string;
queryResponse: ExplorePanelData; queryResponse: ExplorePanelData;
} }
@ -240,14 +240,14 @@ export const addResultsToCacheAction = createAction<AddResultsToCachePayload>('e
* Clears cache. * Clears cache.
*/ */
export interface ClearCachePayload { export interface ClearCachePayload {
exploreId: ExploreId; exploreId: string;
} }
export const clearCacheAction = createAction<ClearCachePayload>('explore/clearCache'); export const clearCacheAction = createAction<ClearCachePayload>('explore/clearCache');
/** /**
* Adds a query row after the row with the given index. * Adds a query row after the row with the given index.
*/ */
export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult<void> { export function addQueryRow(exploreId: string, index: number): ThunkResult<void> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const queries = getState().explore.panes[exploreId]!.queries; const queries = getState().explore.panes[exploreId]!.queries;
let datasourceOverride = undefined; let datasourceOverride = undefined;
@ -270,7 +270,7 @@ export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult<vo
/** /**
* Cancel running queries * Cancel running queries
*/ */
export function cancelQueries(exploreId: ExploreId): ThunkResult<void> { export function cancelQueries(exploreId: string): ThunkResult<void> {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(scanStopAction({ exploreId })); dispatch(scanStopAction({ exploreId }));
dispatch(cancelQueriesAction({ exploreId })); dispatch(cancelQueriesAction({ exploreId }));
@ -352,7 +352,7 @@ export const changeQueries = createAsyncThunk<void, ChangeQueriesPayload>(
* @param targetDataSource * @param targetDataSource
*/ */
export const importQueries = ( export const importQueries = (
exploreId: ExploreId, exploreId: string,
queries: DataQuery[], queries: DataQuery[],
sourceDataSource: DataSourceApi | undefined | null, sourceDataSource: DataSourceApi | undefined | null,
targetDataSource: DataSourceApi, targetDataSource: DataSourceApi,
@ -425,7 +425,7 @@ export const importQueries = (
* @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`. * @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`.
*/ */
export function modifyQueries( export function modifyQueries(
exploreId: ExploreId, exploreId: string,
modification: QueryFixAction, modification: QueryFixAction,
modifier: (query: DataQuery, modification: QueryFixAction) => Promise<DataQuery> modifier: (query: DataQuery, modification: QueryFixAction) => Promise<DataQuery>
): ThunkResult<void> { ): ThunkResult<void> {
@ -453,7 +453,7 @@ async function handleHistory(
history: Array<HistoryItem<DataQuery>>, history: Array<HistoryItem<DataQuery>>,
datasource: DataSourceApi, datasource: DataSourceApi,
queries: DataQuery[], queries: DataQuery[],
exploreId: ExploreId exploreId: string
) { ) {
const datasourceId = datasource.meta.id; const datasourceId = datasource.meta.id;
const nextHistory = updateHistory(history, datasourceId, queries); const nextHistory = updateHistory(history, datasourceId, queries);
@ -465,12 +465,12 @@ async function handleHistory(
// used filters. Instead, we refresh the query history list. // used filters. Instead, we refresh the query history list.
// TODO: run only if Query History list is opened (#47252) // TODO: run only if Query History list is opened (#47252)
for (const exploreId in state.panes) { for (const exploreId in state.panes) {
await dispatch(loadRichHistory(exploreId as ExploreId)); await dispatch(loadRichHistory(exploreId));
} }
} }
interface RunQueriesOptions { interface RunQueriesOptions {
exploreId: ExploreId; exploreId: string;
preserveCache?: boolean; preserveCache?: boolean;
} }
/** /**
@ -653,7 +653,7 @@ const groupDataQueries = async (datasources: DataQuery[], scopedVars: ScopedVars
}; };
type HandleSupplementaryQueriesOptions = { type HandleSupplementaryQueriesOptions = {
exploreId: ExploreId; exploreId: string;
transaction: QueryTransaction; transaction: QueryTransaction;
datasourceInstance: DataSourceApi; datasourceInstance: DataSourceApi;
newQuerySource: Observable<ExplorePanelData>; newQuerySource: Observable<ExplorePanelData>;
@ -772,7 +772,7 @@ function canReuseSupplementaryQueryData(
* Reset queries to the given queries. Any modifications will be discarded. * Reset queries to the given queries. Any modifications will be discarded.
* Use this action for clicks on query examples. Triggers a query run. * Use this action for clicks on query examples. Triggers a query run.
*/ */
export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): ThunkResult<void> { export function setQueries(exploreId: string, rawQueries: DataQuery[]): ThunkResult<void> {
return (dispatch, getState) => { return (dispatch, getState) => {
// Inject react keys into query objects // Inject react keys into query objects
const queries = getState().explore.panes[exploreId]!.queries; const queries = getState().explore.panes[exploreId]!.queries;
@ -787,7 +787,7 @@ export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): Thunk
* @param exploreId Explore area * @param exploreId Explore area
* @param scanner Function that a) returns a new time range and b) triggers a query run for the new range * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range
*/ */
export function scanStart(exploreId: ExploreId): ThunkResult<void> { export function scanStart(exploreId: string): ThunkResult<void> {
return (dispatch, getState) => { return (dispatch, getState) => {
// Register the scanner // Register the scanner
dispatch(scanStartAction({ exploreId })); dispatch(scanStartAction({ exploreId }));
@ -799,7 +799,7 @@ export function scanStart(exploreId: ExploreId): ThunkResult<void> {
}; };
} }
export function addResultsToCache(exploreId: ExploreId): ThunkResult<void> { export function addResultsToCache(exploreId: string): ThunkResult<void> {
return (dispatch, getState) => { return (dispatch, getState) => {
const queryResponse = getState().explore.panes[exploreId]!.queryResponse; const queryResponse = getState().explore.panes[exploreId]!.queryResponse;
const absoluteRange = getState().explore.panes[exploreId]!.absoluteRange; const absoluteRange = getState().explore.panes[exploreId]!.absoluteRange;
@ -812,7 +812,7 @@ export function addResultsToCache(exploreId: ExploreId): ThunkResult<void> {
}; };
} }
export function clearCache(exploreId: ExploreId): ThunkResult<void> { export function clearCache(exploreId: string): ThunkResult<void> {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(clearCacheAction({ exploreId })); dispatch(clearCacheAction({ exploreId }));
}; };
@ -821,7 +821,7 @@ export function clearCache(exploreId: ExploreId): ThunkResult<void> {
/** /**
* Initializes loading logs volume data and stores emitted value. * Initializes loading logs volume data and stores emitted value.
*/ */
export function loadSupplementaryQueryData(exploreId: ExploreId, type: SupplementaryQueryType): ThunkResult<void> { export function loadSupplementaryQueryData(exploreId: string, type: SupplementaryQueryType): ThunkResult<void> {
return (dispatch, getState) => { return (dispatch, getState) => {
const { supplementaryQueries } = getState().explore.panes[exploreId]!; const { supplementaryQueries } = getState().explore.panes[exploreId]!;
const dataProvider = supplementaryQueries[type].dataProvider; const dataProvider = supplementaryQueries[type].dataProvider;
@ -844,7 +844,7 @@ export function loadSupplementaryQueryData(exploreId: ExploreId, type: Supplemen
} }
export function setSupplementaryQueryEnabled( export function setSupplementaryQueryEnabled(
exploreId: ExploreId, exploreId: string,
enabled: boolean, enabled: boolean,
type: SupplementaryQueryType type: SupplementaryQueryType
): ThunkResult<void> { ): ThunkResult<void> {

View File

@ -1,19 +0,0 @@
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,26 +1,14 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { ExploreId, ExploreState, StoreState } from 'app/types'; import { ExploreItemState, StoreState } from 'app/types';
export const selectPanes = (state: Pick<StoreState, 'explore'>) => state.explore.panes; export const selectPanes = (state: Pick<StoreState, 'explore'>) => state.explore.panes;
/** export const selectPanesEntries = createSelector<
* Explore renders panes by iterating over the panes object. This selector ensures that entries in the returned panes object [(state: Pick<StoreState, 'explore'>) => Record<string, ExploreItemState | undefined>],
* are in the correct order. Array<[string, ExploreItemState]>
*/ >(selectPanes, Object.entries);
export const selectOrderedExplorePanes = createSelector(selectPanes, (panes) => {
const orderedPanes: ExploreState['panes'] = {};
if (panes.left) { export const isSplit = createSelector(selectPanesEntries, (panes) => panes.length > 1);
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: string) => createSelector(selectPanes, (panes) => panes[exploreId]);
export const getExploreItemSelector = (exploreId: ExploreId) =>
createSelector(selectPanes, (panes) => panes[exploreId]);

View File

@ -2,7 +2,7 @@ import { reducerTester } from 'test/core/redux/reducerTester';
import { dateTime } from '@grafana/data'; import { dateTime } from '@grafana/data';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
import { ExploreId, ExploreItemState } from 'app/types'; import { ExploreItemState } from 'app/types';
import { createDefaultInitialState } from './helpers'; import { createDefaultInitialState } from './helpers';
import { changeRangeAction, timeReducer, updateTime } from './time'; import { changeRangeAction, timeReducer, updateTime } from './time';
@ -30,7 +30,7 @@ describe('Explore item reducer', () => {
describe('When time is updated', () => { describe('When time is updated', () => {
it('Time service is re-initialized and template service is updated with the new time range', async () => { it('Time service is re-initialized and template service is updated with the new time range', async () => {
const { dispatch } = configureStore(createDefaultInitialState().defaultInitialState as any); const { dispatch } = configureStore(createDefaultInitialState().defaultInitialState as any);
dispatch(updateTime({ exploreId: ExploreId.left })); dispatch(updateTime({ exploreId: 'left' }));
expect(mockTimeSrv.init).toBeCalled(); expect(mockTimeSrv.init).toBeCalled();
expect(mockTemplateSrv.updateTimeRange).toBeCalledWith(MOCK_TIME_RANGE); expect(mockTemplateSrv.updateTimeRange).toBeCalledWith(MOCK_TIME_RANGE);
}); });
@ -46,7 +46,7 @@ describe('Explore item reducer', () => {
} as unknown as ExploreItemState) } as unknown as ExploreItemState)
.whenActionIsDispatched( .whenActionIsDispatched(
changeRangeAction({ changeRangeAction({
exploreId: ExploreId.left, exploreId: 'left',
absoluteRange: { from: 1546297200000, to: 1546383600000 }, absoluteRange: { from: 1546297200000, to: 1546383600000 },
range: { from: dateTime('2019-01-01'), to: dateTime('2019-01-02'), raw: { from: 'now-1d', to: 'now' } }, range: { from: dateTime('2019-01-01'), to: dateTime('2019-01-02'), raw: { from: 'now-1d', to: 'now' } },
}) })

View File

@ -7,7 +7,6 @@ import { getTimeRange, refreshIntervalToSortOrder, stopQueryState } from 'app/co
import { sortLogsResult } from 'app/features/logs/utils'; import { sortLogsResult } from 'app/features/logs/utils';
import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors'; import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors';
import { ExploreItemState, ThunkResult } from 'app/types'; import { ExploreItemState, ThunkResult } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { getTimeSrv } from '../../dashboard/services/TimeSrv'; import { getTimeSrv } from '../../dashboard/services/TimeSrv';
import { TimeModel } from '../../dashboard/state/TimeModel'; import { TimeModel } from '../../dashboard/state/TimeModel';
@ -20,7 +19,7 @@ import { runQueries } from './query';
// //
export interface ChangeRangePayload { export interface ChangeRangePayload {
exploreId: ExploreId; exploreId: string;
range: TimeRange; range: TimeRange;
absoluteRange: AbsoluteTimeRange; absoluteRange: AbsoluteTimeRange;
} }
@ -30,13 +29,13 @@ export const changeRangeAction = createAction<ChangeRangePayload>('explore/chang
* Change the time range of Explore. Usually called from the Timepicker or a graph interaction. * Change the time range of Explore. Usually called from the Timepicker or a graph interaction.
*/ */
export interface ChangeRefreshIntervalPayload { export interface ChangeRefreshIntervalPayload {
exploreId: ExploreId; exploreId: string;
refreshInterval: string; refreshInterval: string;
} }
export const changeRefreshInterval = createAction<ChangeRefreshIntervalPayload>('explore/changeRefreshInterval'); export const changeRefreshInterval = createAction<ChangeRefreshIntervalPayload>('explore/changeRefreshInterval');
export const updateTimeRange = (options: { export const updateTimeRange = (options: {
exploreId: ExploreId; exploreId: string;
rawRange?: RawTimeRange; rawRange?: RawTimeRange;
absoluteRange?: AbsoluteTimeRange; absoluteRange?: AbsoluteTimeRange;
}): ThunkResult<void> => { }): ThunkResult<void> => {
@ -44,8 +43,8 @@ export const updateTimeRange = (options: {
const { syncedTimes } = getState().explore; const { syncedTimes } = getState().explore;
if (syncedTimes) { if (syncedTimes) {
Object.keys(getState().explore.panes).forEach((exploreId) => { Object.keys(getState().explore.panes).forEach((exploreId) => {
dispatch(updateTime({ ...options, exploreId: exploreId as ExploreId })); dispatch(updateTime({ ...options, exploreId }));
dispatch(runQueries({ exploreId: exploreId as ExploreId, preserveCache: true })); dispatch(runQueries({ exploreId: exploreId, preserveCache: true }));
}); });
} else { } else {
dispatch(updateTime({ ...options })); dispatch(updateTime({ ...options }));
@ -55,7 +54,7 @@ export const updateTimeRange = (options: {
}; };
export const updateTime = (config: { export const updateTime = (config: {
exploreId: ExploreId; exploreId: string;
rawRange?: RawTimeRange; rawRange?: RawTimeRange;
absoluteRange?: AbsoluteTimeRange; absoluteRange?: AbsoluteTimeRange;
}): ThunkResult<void> => { }): ThunkResult<void> => {
@ -104,14 +103,14 @@ export const updateTime = (config: {
* Syncs time interval, if they are not synced on both panels in a split mode. * Syncs time interval, if they are not synced on both panels in a split mode.
* Unsyncs time interval, if they are synced on both panels in a split mode. * Unsyncs time interval, if they are synced on both panels in a split mode.
*/ */
export function syncTimes(exploreId: ExploreId): ThunkResult<void> { export function syncTimes(exploreId: string): ThunkResult<void> {
return (dispatch, getState) => { return (dispatch, getState) => {
const range = getState().explore.panes[exploreId]!.range.raw; const range = getState().explore.panes[exploreId]!.range.raw;
Object.keys(getState().explore.panes) Object.keys(getState().explore.panes)
.filter((key) => key !== exploreId) .filter((key) => key !== exploreId)
.forEach((exploreId) => { .forEach((exploreId) => {
dispatch(updateTimeRange({ exploreId: exploreId as ExploreId, rawRange: range })); dispatch(updateTimeRange({ exploreId, rawRange: range }));
}); });
const isTimeSynced = getState().explore.syncedTimes; const isTimeSynced = getState().explore.syncedTimes;
@ -132,7 +131,7 @@ export function makeAbsoluteTime(): ThunkResult<void> {
Object.entries(getState().explore.panes).forEach(([exploreId, exploreItemState]) => { Object.entries(getState().explore.panes).forEach(([exploreId, exploreItemState]) => {
const range = getTimeRange(timeZone, exploreItemState!.range.raw, fiscalYearStartMonth); const range = getTimeRange(timeZone, exploreItemState!.range.raw, fiscalYearStartMonth);
const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() }; const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
dispatch(updateTime({ exploreId: exploreId as ExploreId, absoluteRange })); dispatch(updateTime({ exploreId, absoluteRange }));
}); });
}; };
} }

View File

@ -3,15 +3,13 @@ import React, { useCallback } from 'react';
import { RefreshPicker } from '@grafana/ui'; import { RefreshPicker } from '@grafana/ui';
import { useDispatch } from 'app/types'; import { useDispatch } from 'app/types';
import { ExploreId } from '../../types';
import { setPausedStateAction, runQueries, clearLogs } from './state/query'; import { setPausedStateAction, runQueries, clearLogs } from './state/query';
import { changeRefreshInterval } from './state/time'; import { changeRefreshInterval } from './state/time';
/** /**
* Hook that gives you all the functions needed to control the live tailing. * Hook that gives you all the functions needed to control the live tailing.
*/ */
export function useLiveTailControls(exploreId: ExploreId) { export function useLiveTailControls(exploreId: string) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const pause = useCallback(() => { const pause = useCallback(() => {
@ -52,7 +50,7 @@ export function useLiveTailControls(exploreId: ExploreId) {
} }
type Props = { type Props = {
exploreId: ExploreId; exploreId: string;
children: (controls: ReturnType<typeof useLiveTailControls>) => React.ReactElement; children: (controls: ReturnType<typeof useLiveTailControls>) => React.ReactElement;
}; };

View File

@ -13,7 +13,6 @@ import {
TypeaheadOutput, TypeaheadOutput,
withTheme2, withTheme2,
} from '@grafana/ui'; } from '@grafana/ui';
import { ExploreId } from 'app/types';
// Utils & Services // Utils & Services
// dom also includes Element polyfills // dom also includes Element polyfills
@ -30,7 +29,7 @@ export interface CloudWatchLogsQueryFieldProps
absoluteRange: AbsoluteTimeRange; absoluteRange: AbsoluteTimeRange;
onLabelsRefresh?: () => void; onLabelsRefresh?: () => void;
ExtraFieldElement?: ReactNode; ExtraFieldElement?: ReactNode;
exploreId: ExploreId; exploreId: string;
query: CloudWatchLogsQuery; query: CloudWatchLogsQuery;
} }
const plugins: Array<Plugin<Editor>> = [ const plugins: Array<Plugin<Editor>> = [

View File

@ -15,21 +15,13 @@ import {
DataQueryResponse, DataQueryResponse,
ExplorePanelsState, ExplorePanelsState,
SupplementaryQueryType, SupplementaryQueryType,
UrlQueryMap,
} from '@grafana/data'; } from '@grafana/data';
import { RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistoryTypes'; import { RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistoryTypes';
import { CorrelationData } from '../features/correlations/useCorrelations'; import { CorrelationData } from '../features/correlations/useCorrelations';
export enum ExploreId { export type ExploreQueryParams = UrlQueryMap;
left = 'left',
right = 'right',
}
export type ExploreQueryParams = {
left?: string;
right?: string;
};
/** /**
* Global Explore state * Global Explore state
*/ */
@ -39,13 +31,7 @@ export interface ExploreState {
*/ */
syncedTimes: boolean; syncedTimes: boolean;
// This being optional wouldn't be needed with noUncheckedIndexedAccess set to true, but it cause more than 5k errors currently. panes: Record<string, ExploreItemState | undefined>;
// In order to be safe, we declare each item as pssobly undefined to force existence checks.
// This will have the side effect of also forcing undefined checks when iterating over this object entries, but
// it's better to error on the safer side.
panes: {
[paneId in ExploreId]?: ExploreItemState;
};
correlations?: CorrelationData[]; correlations?: CorrelationData[];

View File

@ -20,12 +20,17 @@ export interface Props {
* Wrapps component in redux store provider, Router and GrafanaContext * Wrapps component in redux store provider, Router and GrafanaContext
*/ */
export function TestProvider(props: Props) { export function TestProvider(props: Props) {
const { store = configureStore(props.storeState), grafanaContext = getGrafanaContextMock(), children } = props; const { store = configureStore(props.storeState), children } = props;
const context = {
...getGrafanaContextMock(),
...props.grafanaContext,
};
return ( return (
<Provider store={store}> <Provider store={store}>
<Router history={locationService.getHistory()}> <Router history={locationService.getHistory()}>
<GrafanaContext.Provider value={grafanaContext}>{children}</GrafanaContext.Provider> <GrafanaContext.Provider value={context}>{children}</GrafanaContext.Provider>
</Router> </Router>
</Provider> </Provider>
); );