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, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
],
"public/app/core/utils/fetch.ts:5381": [
[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, "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": [
[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.", "1"]
],
"public/app/features/explore/hooks/useStateSync.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"]
],
"public/app/features/explore/spec/helper/setup.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
@ -2582,24 +2573,9 @@ exports[`better eslint`] = {
"public/app/features/explore/spec/queryHistory.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/explore/state/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": [
[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": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[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.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');
canvases.should('have.length', 1);
// Both queries above should have been run and be shown in the query history
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
cy.get('button[title="Delete query"]').each((button) => {

View File

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

View File

@ -2,15 +2,13 @@ import { dateTime, ExploreUrlState, LogsSortOrder } from '@grafana/data';
import { serializeStateToUrlParam } from '@grafana/data/src/utils/url';
import { RefreshPicker } from '@grafana/ui';
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 {
buildQueryTransaction,
DEFAULT_RANGE,
hasNonEmptyQuery,
parseUrlState,
refreshIntervalToSortOrder,
updateHistory,
getExploreUrl,
@ -42,67 +40,6 @@ jest.mock('@grafana/runtime', () => ({
}));
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', () => {
it('returns url parameter value for a state object', () => {
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', () => {
@ -304,21 +186,21 @@ describe('when buildQueryTransaction', () => {
const queries = [{ refId: 'A' }];
const queryOptions = { maxDataPoints: 1000, minInterval: '15s' };
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);
});
it('it should calculate interval taking minInterval into account', () => {
const queries = [{ refId: 'A' }];
const queryOptions = { maxDataPoints: 1000, minInterval: '15s' };
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);
});
it('it should calculate interval taking maxDataPoints into account', () => {
const queries = [{ refId: 'A' }];
const queryOptions = { maxDataPoints: 10, minInterval: '15s' };
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');
});
});

View File

@ -1,3 +1,4 @@
import { nanoid } from '@reduxjs/toolkit';
import { omit } from 'lodash';
import { Unsubscribable } from 'rxjs';
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 { PanelModel } from 'app/features/dashboard/state';
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 { getNextRefIdChar } from './query';
export const DEFAULT_RANGE = {
from: 'now-1h',
to: 'now',
};
export const DEFAULT_UI_STATE = {
dedupStrategy: LogsDedupStrategy.none,
};
@ -58,6 +54,10 @@ export interface GetExploreUrlArguments {
timeSrv: TimeSrv;
}
export function generateExploreId() {
return nanoid(3);
}
/**
* 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 || {};
state = {
...state,
datasource: exploreDatasource.name,
context: 'explore',
datasource: exploreDatasource.uid,
queries: exploreDatasource.interpolateVariablesInQueries(exploreTargets, scopedVars),
};
} else {
state = {
...state,
datasource: exploreDatasource.name,
context: 'explore',
datasource: exploreDatasource.uid,
queries: exploreTargets,
};
}
const exploreState = JSON.stringify(state);
url = urlUtil.renderUrl('/explore', { left: exploreState });
const exploreState = JSON.stringify({ [generateExploreId()]: state });
url = urlUtil.renderUrl('/explore', { panes: exploreState, schemaVersion: 1 });
}
return url;
}
export function buildQueryTransaction(
exploreId: ExploreId,
exploreId: string,
queries: DataQuery[],
queryOptions: QueryOptions,
range: TimeRange,
@ -172,16 +170,6 @@ export function buildQueryTransaction(
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 => {
if (!text) {
return;
@ -208,40 +196,6 @@ export const safeStringifyValue = (value: unknown, space?: number) => {
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 {
return `Q-${uuidv4()}-${index}`;
}

View File

@ -134,7 +134,6 @@ export const createUrlFromRichHistory = (query: RichHistoryQuery) => {
range: { from: 'now-1h', to: 'now' },
datasource: query.datasourceName,
queries: query.queries,
context: 'explore',
};
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 { contextSrv } from 'app/core/services/context_srv';
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';
@ -57,7 +57,7 @@ interface SubmissionError {
interface Props {
onClose: () => void;
exploreId: ExploreId;
exploreId: string;
}
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 { DashboardSearchItemType } from 'app/features/search/types';
import { configureStore } from 'app/store/configureStore';
import { ExploreId, ExploreState } from 'app/types';
import { ExploreState } from 'app/types';
import { createEmptyQueryResponse } from '../state/utils';
@ -52,7 +52,7 @@ describe('AddToDashboardButton', () => {
});
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 });
expect(button).toBeDisabled();
@ -81,7 +81,7 @@ describe('AddToDashboardButton', () => {
});
it('Opens and closes the modal correctly', async () => {
setup(<AddToDashboard exploreId={ExploreId.left} />);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
@ -96,7 +96,7 @@ describe('AddToDashboardButton', () => {
const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
const pushSpy = jest.spyOn(locationService, 'push');
setup(<AddToDashboard exploreId={ExploreId.left} />);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
@ -115,7 +115,7 @@ describe('AddToDashboardButton', () => {
const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
const pushSpy = jest.spyOn(locationService, 'push');
setup(<AddToDashboard exploreId={ExploreId.left} />);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
@ -134,7 +134,7 @@ describe('AddToDashboardButton', () => {
// @ts-expect-error global.open should return a Window, but is not implemented in js-dom.
const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
setup(<AddToDashboard exploreId={ExploreId.left} />);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
@ -148,7 +148,7 @@ describe('AddToDashboardButton', () => {
it('Navigates to the new dashboard', async () => {
const pushSpy = jest.spyOn(locationService, 'push');
setup(<AddToDashboard exploreId={ExploreId.left} />);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
@ -165,7 +165,7 @@ describe('AddToDashboardButton', () => {
describe('Save to existing dashboard', () => {
it('Renders the dashboard picker when switching to "Existing Dashboard"', async () => {
setup(<AddToDashboard exploreId={ExploreId.left} />);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
@ -178,7 +178,7 @@ describe('AddToDashboardButton', () => {
it('Does not submit if no dashboard is selected', async () => {
locationService.push = jest.fn();
setup(<AddToDashboard exploreId={ExploreId.left} />);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
@ -211,7 +211,7 @@ describe('AddToDashboardButton', () => {
},
]);
setup(<AddToDashboard exploreId={ExploreId.left} />);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
@ -252,7 +252,7 @@ describe('AddToDashboardButton', () => {
},
]);
setup(<AddToDashboard exploreId={ExploreId.left} />);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
@ -290,7 +290,7 @@ describe('AddToDashboardButton', () => {
return true;
}
});
setup(<AddToDashboard exploreId={ExploreId.left} />);
setup(<AddToDashboard exploreId={'left'} />);
await openModal('Add panel to existing dashboard');
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
});
@ -303,7 +303,7 @@ describe('AddToDashboardButton', () => {
return true;
}
});
setup(<AddToDashboard exploreId={ExploreId.left} />);
setup(<AddToDashboard exploreId={'left'} />);
await openModal('Add panel to new dashboard');
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
});
@ -322,7 +322,7 @@ describe('AddToDashboardButton', () => {
jest.spyOn(global, 'open').mockReturnValue(null);
const removeDashboardSpy = jest.spyOn(initDashboard, 'removeDashboardToFetchFromLocalStorage');
setup(<AddToDashboard exploreId={ExploreId.left} />);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
@ -341,7 +341,7 @@ describe('AddToDashboardButton', () => {
throw 'SOME ERROR';
});
setup(<AddToDashboard exploreId={ExploreId.left} />);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
@ -367,7 +367,7 @@ describe('AddToDashboardButton', () => {
},
]);
setup(<AddToDashboard exploreId={ExploreId.left} />);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
@ -391,7 +391,7 @@ describe('AddToDashboardButton', () => {
it('Shows an error if an unknown error happens', async () => {
jest.spyOn(api, 'setDashboardInLocalStorage').mockRejectedValue('SOME ERROR');
setup(<AddToDashboard exploreId={ExploreId.left} />);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();

View File

@ -1,14 +1,14 @@
import React, { useState } from 'react';
import { ToolbarButton } from '@grafana/ui';
import { ExploreId, useSelector } from 'app/types';
import { useSelector } from 'app/types';
import { getExploreItemSelector } from '../state/selectors';
import { AddToDashboardModal } from './AddToDashboardModal';
interface Props {
exploreId: ExploreId;
exploreId: string;
}
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 { selectors } from '@grafana/e2e-selectors';
import { configureStore } from 'app/store/configureStore';
import { ExploreId } from 'app/types';
import { Explore, Props } from './Explore';
import { initialExploreState } from './state/main';
@ -61,7 +60,7 @@ const dummyProps: Props = {
QueryEditorHelp: {},
},
} as DataSourceApi,
exploreId: ExploreId.left,
exploreId: 'left',
loading: false,
modifyQueries: 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 { StoreState } from 'app/types';
import { AbsoluteTimeEvent } from 'app/types/events';
import { ExploreId } from 'app/types/explore';
import { getTimeZone } from '../profile/state/selectors';
@ -98,7 +97,7 @@ const getStyles = (theme: GrafanaTheme2) => {
};
export interface ExploreProps extends Themeable2 {
exploreId: ExploreId;
exploreId: string;
theme: GrafanaTheme2;
eventBus: EventBus;
}

View File

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

View File

@ -4,8 +4,6 @@ import React, { ComponentProps } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { serializeStateToUrlParam } from '@grafana/data';
import { config } from '@grafana/runtime';
import { ExploreId } from 'app/types';
import { makeLogsQueryResponse } from './spec/helper/query';
import { setupExplore, tearDown, waitForExplore } from './spec/helper/setup';
@ -101,7 +99,7 @@ describe('ExplorePage', () => {
orgId: '1',
};
const { datasources, location } = setupExplore({ urlParams });
const { datasources } = setupExplore({ urlParams });
jest.mocked(datasources.loki.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(`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
expect(datasources.loki.query).toBeCalledTimes(1);
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 () => {
const urlParams = {
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
@ -207,7 +177,7 @@ describe('ExplorePage', () => {
const splitButton = await screen.findByText(/split/i);
await userEvent.click(splitButton);
await waitForExplore(ExploreId.left);
await waitForExplore('left');
expect(await screen.findAllByLabelText('Widen pane')).toHaveLength(2);
expect(screen.queryByLabelText('Narrow pane')).not.toBeInTheDocument();
@ -226,276 +196,4 @@ describe('ExplorePage', () => {
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 { GrafanaRouteComponentProps } from 'app/core/navigation/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 { ExplorePaneContainer } from './ExplorePaneContainer';
import { useExploreCorrelations } from './hooks/useExploreCorrelations';
import { useExplorePageTitle } from './hooks/useExplorePageTitle';
import { useStateSync } from './hooks/useStateSync';
import { useStopQueries } from './hooks/useStopQueries';
import { useTimeSrvFix } from './hooks/useTimeSrvFix';
import { splitSizeUpdateAction } from './state/main';
import { selectOrderedExplorePanes } from './state/selectors';
import { isSplit, selectPanesEntries } from './state/selectors';
const styles = {
pageScrollbarWrapper: css`
@ -32,10 +31,14 @@ const styles = {
};
export default function ExplorePage(props: GrafanaRouteComponentProps<{}, ExploreQueryParams>) {
useStopQueries();
useTimeSrvFix();
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();
const dispatch = useDispatch();
const { keybindings, chrome } = useGrafana();
@ -45,7 +48,8 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
const minWidth = 200;
const exploreState = useSelector((state) => state.explore);
const panes = useSelector(selectOrderedExplorePanes);
const panes = useSelector(selectPanesEntries);
const hasSplit = useSelector(isSplit);
useEffect(() => {
//This is needed for breadcrumbs and topnav.
@ -65,7 +69,7 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
} else {
dispatch(
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);
};
const hasSplit = Object.entries(panes).length > 1;
let widthCalc = 0;
if (hasSplit) {
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) {
widthCalc = Math.floor(windowWidth / 2);
} else if (rightPaneWidthRatio !== undefined) {
@ -87,7 +90,7 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
return (
<div className={styles.pageScrollbarWrapper}>
<ExploreActions exploreIdLeft={ExploreId.left} exploreIdRight={ExploreId.right} />
<ExploreActions />
<SplitPaneWrapper
splitOrientation="vertical"
@ -97,16 +100,12 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
primary="second"
splitVisible={hasSplit}
paneStyle={{ overflow: 'auto', display: 'flex', flexDirection: 'column' }}
onDragFinished={(size) => {
if (size) {
updateSplitSize(size);
}
}}
onDragFinished={(size) => size && updateSplitSize(size)}
>
{Object.keys(panes).map((exploreId) => {
{panes.map(([exploreId]) => {
return (
<ErrorBoundaryAlert key={exploreId} style="page">
<ExplorePaneContainer exploreId={exploreId as ExploreId} />
<ExplorePaneContainer exploreId={exploreId} />
</ErrorBoundaryAlert>
);
})}

View File

@ -1,14 +1,15 @@
import { css } from '@emotion/css';
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import { connect } from 'react-redux';
import { EventBusSrv, GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { useStyles2 } from '@grafana/ui';
import { StoreState } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { stopQueryState } from 'app/core/utils/explore';
import { StoreState, useSelector } from 'app/types';
import Explore from './Explore';
import { getExploreItemSelector } from './state/selectors';
const getStyles = (theme: GrafanaTheme2) => {
return {
@ -26,7 +27,7 @@ const getStyles = (theme: GrafanaTheme2) => {
};
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
*/
function ExplorePaneContainerUnconnected({ exploreId }: Props) {
useStopQueries(exploreId);
const styles = useStyles2(getStyles);
const eventBus = useRef(new EventBusSrv());
const ref = useRef(null);
@ -64,3 +66,15 @@ function mapStateToProps(state: StoreState, props: Props) {
const connector = connect(mapStateToProps);
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 { LoadingState, InternalTimeZones, getDefaultTimeRange } from '@grafana/data';
import { ExploreId } from 'app/types';
import { ExploreQueryInspector } from './ExploreQueryInspector';
@ -38,7 +37,7 @@ const setup = (propOverrides = {}) => {
const props: ExploreQueryInspectorProps = {
loading: false,
width: 100,
exploreId: ExploreId.left,
exploreId: 'left',
onClose: jest.fn(),
timeZone: InternalTimeZones.utc,
queryResponse: {

View File

@ -10,13 +10,13 @@ import { InspectErrorTab } from 'app/features/inspector/InspectErrorTab';
import { InspectJSONTab } from 'app/features/inspector/InspectJSONTab';
import { InspectStatsTab } from 'app/features/inspector/InspectStatsTab';
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';
interface DispatchProps {
width: number;
exploreId: ExploreId;
exploreId: string;
timeZone: TimeZone;
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 item: ExploreItemState = explore.panes[exploreId]!;
const { queryResponse } = item;

View File

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

View File

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

View File

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

View File

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

View File

@ -21,7 +21,7 @@ import {
import { DataQuery } from '@grafana/schema';
import { Collapse } from '@grafana/ui';
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 {
@ -41,7 +41,7 @@ import { LogsCrossFadeTransition } from './utils/LogsCrossFadeTransition';
interface LogsContainerProps extends PropsFromRedux {
width: number;
exploreId: ExploreId;
exploreId: string;
scanRange?: RawTimeRange;
syncedTimes: boolean;
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 item: ExploreItemState = explore.panes[exploreId]!;
const {

View File

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

View File

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

View File

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

View File

@ -6,7 +6,6 @@ import { reportInteraction } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { getNextRefIdChar } from 'app/core/utils/query';
import { useDispatch, useSelector } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { getDatasourceSrv } from '../plugins/datasource_srv';
import { QueryEditorRows } from '../query/components/QueryEditorRows';
@ -15,10 +14,10 @@ import { changeQueries, runQueries } from './state/query';
import { getExploreItemSelector } from './state/selectors';
interface Props {
exploreId: ExploreId;
exploreId: string;
}
const makeSelectors = (exploreId: ExploreId) => {
const makeSelectors = (exploreId: string) => {
const exploreItemSelector = getExploreItemSelector(exploreId);
return {
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 { 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';
@ -52,7 +52,7 @@ const dataFrame = toDataFrame({
});
const defaultProps = {
exploreId: ExploreId.left,
exploreId: 'left',
loading: false,
width: 800,
onCellFilterAdded: jest.fn(),

View File

@ -8,7 +8,7 @@ import { Collapse, RadioButtonGroup, Table, AdHocFilterItem } from '@grafana/ui'
import { config } from 'app/core/config';
import { PANEL_BORDER } from 'app/core/constants';
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 RawListContainer from '../PrometheusListView/RawListContainer';
@ -17,7 +17,7 @@ import { getFieldLinksForExplore } from '../utils/links';
interface RawPrometheusContainerProps {
ariaLabel?: string;
exploreId: ExploreId;
exploreId: string;
width: number;
timeZone: TimeZone;
onCellFilterAdded?: (filter: AdHocFilterItem) => void;

View File

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

View File

@ -3,12 +3,10 @@ import React from 'react';
import { LoadingState } from '@grafana/data';
import { useSelector } from 'app/types';
import { ExploreId } from '../../types';
import { ErrorContainer } from './ErrorContainer';
interface Props {
exploreId: ExploreId;
exploreId: string;
}
export function ResponseErrorContainer(props: Props) {
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 { SortOrder } from 'app/core/utils/richHistory';
import { ExploreId } from 'app/types';
import { RichHistory, RichHistoryProps, Tabs } from './RichHistory';
@ -23,7 +22,7 @@ jest.mock('@grafana/runtime', () => ({
const setup = (propOverrides?: Partial<RichHistoryProps>) => {
const props: RichHistoryProps = {
theme: {} as GrafanaTheme2,
exploreId: ExploreId.left,
exploreId: 'left',
height: 100,
activeDatasourceInstance: 'Test datasource',
richHistory: [],

View File

@ -4,7 +4,7 @@ import React, { PureComponent } from 'react';
import { SelectableValue } from '@grafana/data';
import { Themeable2, TabbedContainer, TabConfig, withTheme2 } from '@grafana/ui';
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';
@ -32,14 +32,14 @@ export interface RichHistoryProps extends Themeable2 {
richHistorySettings: RichHistorySettings;
richHistorySearchFilters?: RichHistorySearchFilters;
updateHistorySettings: (settings: RichHistorySettings) => void;
updateHistorySearchFilters: (exploreId: ExploreId, filters: RichHistorySearchFilters) => void;
loadRichHistory: (exploreId: ExploreId) => void;
loadMoreRichHistory: (exploreId: ExploreId) => void;
clearRichHistoryResults: (exploreId: ExploreId) => void;
updateHistorySearchFilters: (exploreId: string, filters: RichHistorySearchFilters) => void;
loadRichHistory: (exploreId: string) => void;
loadMoreRichHistory: (exploreId: string) => void;
clearRichHistoryResults: (exploreId: string) => void;
deleteRichHistory: () => void;
activeDatasourceInstance: string;
firstTab: Tabs;
exploreId: ExploreId;
exploreId: string;
height: number;
onClose: () => void;
}

View File

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

View File

@ -19,9 +19,9 @@ import { setQueries } from 'app/features/explore/state/query';
import { dispatch } from 'app/store/store';
import { StoreState } from 'app/types';
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 { datasourceInstance } = explore.panes[exploreId]!;
return {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,6 @@ import { Provider } from 'react-redux';
import { DataFrame, MutableDataFrame, getDefaultTimeRange, LoadingState } from '@grafana/data';
import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime';
import { ExploreId } from 'app/types';
import { configureStore } from '../../../store/configureStore';
@ -26,7 +25,7 @@ function getTraceView(frames: DataFrame[]) {
return (
<Provider store={store}>
<TraceView
exploreId={ExploreId.left}
exploreId="left"
dataFrames={frames}
splitOpenFn={() => {}}
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 { TempoQuery } from 'app/plugins/datasource/tempo/types';
import { useDispatch, useSelector } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { changePanelState } from '../state/explorePane';
@ -63,7 +62,7 @@ function noop(): {} {
type Props = {
dataFrames: DataFrame[];
splitOpenFn?: SplitOpen;
exploreId?: ExploreId;
exploreId?: string;
scrollElement?: Element;
scrollElementClass?: string;
traceProp: Trace;
@ -251,7 +250,7 @@ export function TraceView(props: Props) {
* @param options
*/
function useFocusSpanLink(options: {
exploreId: ExploreId;
exploreId: string;
splitOpenFn: SplitOpen;
refId?: string;
datasource?: DataSourceApi;

View File

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

View File

@ -5,7 +5,6 @@ import { DataFrame, SplitOpen, PanelData, GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
import { StoreState, useSelector } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { TraceView } from './TraceView';
import TracePageSearchBar from './components/TracePageHeader/TracePageSearchBar';
@ -15,7 +14,7 @@ import { transformDataFrames } from './utils/transform';
interface Props {
dataFrames: DataFrame[];
splitOpenFn: SplitOpen;
exploreId: ExploreId;
exploreId: string;
scrollElement?: Element;
queryResponse: PanelData;
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 { 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() {
const navModel = useNavModel('explore');
export function useExplorePageTitle(params: ExploreQueryParams) {
const navModel = useRef<NavModel>();
navModel.current = useNavModel('explore');
const dsService = useRef(getDatasourceSrv());
const datasourceNames = useSelector((state) =>
Object.values(selectOrderedExplorePanes(state)).map((pane) => pane?.datasourceInstance?.name)
).filter(isTruthy);
useEffect(() => {
if (!params.panes || typeof params.panes !== 'string') {
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 { useEffect, useRef } from 'react';
import {
CoreApp,
serializeStateToUrlParam,
ExploreUrlState,
isDateTime,
TimeRange,
RawTimeRange,
DataSourceApi,
} from '@grafana/data';
import { CoreApp, ExploreUrlState, isDateTime, TimeRange, RawTimeRange, DataSourceApi } from '@grafana/data';
import { DataQuery, DataSourceRef } from '@grafana/schema';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { clearQueryKeys, getLastUsedDatasourceUID, parseUrlState } from 'app/core/utils/explore';
import { clearQueryKeys, getLastUsedDatasourceUID } from 'app/core/utils/explore';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { addListener, ExploreId, ExploreItemState, ExploreQueryParams, useDispatch, useSelector } from 'app/types';
import { addListener, ExploreItemState, ExploreQueryParams, useDispatch, useSelector } from 'app/types';
import { changeDatasource } from '../state/datasource';
import { initializeExplore } from '../state/explorePane';
import { clearPanes, splitClose, splitOpen, syncTimesAction } from '../state/main';
import { runQueries, setQueriesAction } from '../state/query';
import { selectPanes } from '../state/selectors';
import { changeRangeAction, updateTime } from '../state/time';
import { DEFAULT_RANGE } from '../state/utils';
import { withUniqueRefIds } from '../utils/queries';
import { changeDatasource } from '../../state/datasource';
import { initializeExplore } from '../../state/explorePane';
import { clearPanes, splitClose, splitOpen, syncTimesAction } from '../../state/main';
import { runQueries, setQueriesAction } from '../../state/query';
import { selectPanes } from '../../state/selectors';
import { changeRangeAction, updateTime } from '../../state/time';
import { DEFAULT_RANGE } from '../../state/utils';
import { withUniqueRefIds } from '../../utils/queries';
import { isFulfilled } from '../utils';
import { parseURL } from './parseURL';
/**
* Bi-directionally syncs URL changes with Explore's state.
@ -37,15 +32,15 @@ export function useStateSync(params: ExploreQueryParams) {
},
} = useGrafana();
const dispatch = useDispatch();
const statePanes = useSelector(selectPanes);
const panesState = useSelector(selectPanes);
const orgId = useSelector((state) => state.user.orgId);
const prevParams = useRef<ExploreQueryParams>(params);
const prevParams = useRef(params);
const initState = useRef<'notstarted' | 'pending' | 'done'>('notstarted');
useEffect(() => {
// This happens when the user navigates to an explore "empty page" while within Explore.
// ie. by clicking on the explore when explore is active.
if (!params.left && !params.right) {
if (!params.panes) {
initState.current = 'notstarted';
prevParams.current = params;
}
@ -68,22 +63,27 @@ export function useStateSync(params: ExploreQueryParams) {
cancelActiveListeners();
await delay(200);
const panesQueryParams = Object.entries(getState().explore.panes).reduce<Record<string, string>>(
(acc, [id, paneState]) => ({ ...acc, [id]: serializeStateToUrlParam(getUrlStateFromPaneState(paneState)) }),
{}
);
const panesQueryParams = Object.entries(getState().explore.panes).reduce((acc, [id, 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,
// 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(
{ left: panesQueryParams.left, right: panesQueryParams.right, orgId: getState().user.orgId },
replace
);
location.partial({ panes: prevParams.current.panes }, replace);
}
},
})
@ -94,33 +94,29 @@ export function useStateSync(params: ExploreQueryParams) {
}, [dispatch, location]);
useEffect(() => {
const isURLOutOfSync = prevParams.current?.left !== params.left || prevParams.current?.right !== params.right;
const isURLOutOfSync = prevParams.current?.panes !== params.panes;
const urlPanes = {
left: parseUrlState(params.left),
...(params.right && { right: parseUrlState(params.right) }),
};
const urlState = parseURL(params);
async function sync() {
// if navigating the history causes one of the time range to not being equal to all the other ones,
// we set syncedTimes to false to avoid inconsistent UI state.
// Ideally `syncedTimes` should be saved in the URL.
if (Object.values(urlPanes).some(({ range }, _, [{ range: firstRange }]) => !isEqual(range, firstRange))) {
if (Object.values(urlState.panes).some(({ range }, _, [{ range: firstRange }]) => !isEqual(range, firstRange))) {
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 statePane = statePanes[exploreId];
const paneState = panesState[exploreId];
if (statePane !== undefined) {
// TODO: the diff contains panelState updates, but we are currently not handling them.
const update = urlDiff(urlPane, getUrlStateFromPaneState(statePane));
if (paneState !== undefined) {
const update = urlDiff(urlPane, getUrlStateFromPaneState(paneState));
Promise.resolve()
.then(async () => {
if (update.datasource) {
if (update.datasource && datasource) {
await dispatch(changeDatasource(exploreId, datasource));
}
return;
@ -146,20 +142,21 @@ export function useStateSync(params: ExploreQueryParams) {
dispatch(
initializeExplore({
exploreId,
datasource,
datasource: datasource || '',
queries: withUniqueRefIds(queries),
range,
panelsState,
position: i,
})
);
}
}
});
// Close all the panes that are not in the URL but are still in the store
// ie. because the user has navigated back after opening the split view.
Object.keys(statePanes)
.filter((keyInStore) => !Object.keys(urlPanes).includes(keyInStore))
.forEach((paneId) => dispatch(splitClose(paneId as ExploreId)));
Object.keys(panesState)
.filter((keyInStore) => !Object.keys(urlState.panes).includes(keyInStore))
.forEach((paneId) => dispatch(splitClose(paneId)));
}
// This happens when the user first navigates to explore.
@ -172,11 +169,11 @@ export function useStateSync(params: ExploreQueryParams) {
dispatch(clearPanes());
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(
async (paneDatasource) => {
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
? queries.length
? // if we have queries in the URL, we use them
@ -212,7 +209,7 @@ export function useStateSync(params: ExploreQueryParams) {
.then((queries) => {
return dispatch(
initializeExplore({
exploreId: exploreId as ExploreId,
exploreId,
datasource: paneDatasource,
queries,
range,
@ -224,25 +221,39 @@ export function useStateSync(params: ExploreQueryParams) {
);
})
).then((panes) => {
const urlState = panes.reduce<ExploreQueryParams>((acc, { exploreId, state }) => {
return { ...acc, [exploreId]: serializeStateToUrlParam(getUrlStateFromPaneState(state)) };
}, {});
location.partial({ ...urlState, orgId }, true);
const newParams = panes.reduce(
(acc, { exploreId, state }) => {
return {
...acc,
panes: {
...acc.panes,
[exploreId]: getUrlStateFromPaneState(state),
},
};
},
{
panes: {},
}
);
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 = {
left: params.left,
};
if (params.right) {
prevParams.current.right = params.right;
}
prevParams.current = params;
isURLOutOfSync && initState.current === 'done' && sync();
}, [params.left, params.right, dispatch, statePanes, exploreMixedDatasource, orgId, location]);
}, [dispatch, panesState, exploreMixedDatasource, orgId, location, params]);
}
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
* side effects needed.
@ -396,7 +404,7 @@ function pruneObject(obj: object): object | undefined {
return pruned;
}
export const toRawTimeRange = (range: TimeRange): RawTimeRange => {
const toRawTimeRange = (range: TimeRange): RawTimeRange => {
let from = range.raw.from;
if (isDateTime(from)) {
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() {
const { location } = useGrafana();
useEffect(() => {
const searchParams = location.getSearchObject();
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 { serializeStateToUrlParam } from '@grafana/data';
import { changeDatasource } from './helper/interactions';
import { makeLogsQueryResponse } from './helper/query';
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));
});
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 { datasources, location } = setupExplore({ urlParams });
const { datasources } = setupExplore({ urlParams });
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
await waitForExplore();
await changeDatasource('elastic');
await screen.findByText('elastic Editor input:');
expect(datasources.elastic.query).not.toBeCalled();
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 { ExploreId } from '../../../../types';
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);
expect(await selector.findByText('1 queries')).toBeInTheDocument();
@ -12,7 +10,7 @@ export const assertQueryHistoryExists = async (query: string, exploreId: Explore
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);
await waitFor(() => {
expect(selector.getByText(new RegExp(`${expectedQueryTexts.length} queries`))).toBeInTheDocument();
@ -23,10 +21,7 @@ export const assertQueryHistory = async (expectedQueryTexts: string[], exploreId
});
};
export const assertQueryHistoryComment = async (
expectedQueryComments: string[],
exploreId: ExploreId = ExploreId.left
) => {
export const assertQueryHistoryComment = async (expectedQueryComments: string[], exploreId = 'left') => {
const selector = withinExplore(exploreId);
await waitFor(() => {
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 starButtons = selector.getAllByRole('button', { name: /Star query|Unstar query/ });
await waitFor(() =>
@ -49,12 +44,12 @@ export const assertQueryHistoryIsStarred = async (expectedStars: boolean[], expl
export const assertQueryHistoryTabIsSelected = (
tabName: 'Query history' | 'Starred' | 'Settings',
exploreId: ExploreId = ExploreId.left
exploreId = 'left'
) => {
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)');
if (visible) {
expect(filterInput).toBeInTheDocument();
@ -63,14 +58,10 @@ export const assertDataSourceFilterVisibility = (visible: boolean, exploreId: Ex
}
};
export const assertQueryHistoryElementsShown = (
shown: number,
total: number,
exploreId: ExploreId = ExploreId.left
) => {
export const assertQueryHistoryElementsShown = (shown: number, total: number, exploreId = 'left') => {
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();
};

View File

@ -3,8 +3,6 @@ import userEvent from '@testing-library/user-event';
import { selectors } from '@grafana/e2e-selectors';
import { ExploreId } from '../../../../types';
import { withinExplore } from './setup';
export const changeDatasource = async (name: string) => {
@ -14,59 +12,52 @@ export const changeDatasource = async (name: string) => {
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' });
await userEvent.clear(input);
await userEvent.type(input, query);
};
export const runQuery = async (exploreId: ExploreId = ExploreId.left) => {
export const runQuery = async (exploreId = 'left') => {
const explore = withinExplore(exploreId);
const toolbar = within(explore.getByLabelText('Explore toolbar'));
const button = toolbar.getByRole('button', { name: /run query/i });
await userEvent.click(button);
};
export const openQueryHistory = async (exploreId: ExploreId = ExploreId.left) => {
export const openQueryHistory = async (exploreId = 'left') => {
const selector = withinExplore(exploreId);
const button = selector.getByRole('button', { name: 'Rich history button' });
await userEvent.click(button);
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' });
await userEvent.click(closeButton);
};
export const switchToQueryHistoryTab = async (
name: 'Settings' | 'Query History',
exploreId: ExploreId = ExploreId.left
) => {
export const switchToQueryHistoryTab = async (name: 'Settings' | 'Query History', exploreId = 'left') => {
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', {
name: /Change the default active tab from “Query history” to “Starred”/,
});
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.*/);
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);
};
export const commentQueryHistory = async (
queryIndex: number,
comment: string,
exploreId: ExploreId = ExploreId.left
) => {
export const commentQueryHistory = async (queryIndex: number, comment: string, exploreId = 'left') => {
await invokeAction(queryIndex, 'Add comment', exploreId);
const input = withinExplore(exploreId).getByPlaceholderText('An optional description of what the query does.');
await userEvent.clear(input);
@ -74,16 +65,16 @@ export const commentQueryHistory = async (
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);
};
export const loadMoreQueryHistory = async (exploreId: ExploreId = ExploreId.left) => {
export const loadMoreQueryHistory = async (exploreId = 'left') => {
const button = withinExplore(exploreId).getByRole('button', { name: 'Load more' });
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 buttons = selector.getAllByRole('button', { name: actionAccessibleName });
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 { LokiQuery } from '../../../../plugins/datasource/loki/types';
import { ExploreId, ExploreQueryParams } from '../../../../types';
import { ExploreQueryParams } from '../../../../types';
import { initialUserState } from '../../../profile/state/reducers';
import ExplorePage from '../../ExplorePage';
@ -42,7 +42,7 @@ type DatasourceSetup = { settings: DataSourceInstanceSettings; api: DataSourceAp
type SetupOptions = {
clearLocalStorage?: boolean;
datasources?: DatasourceSetup[];
urlParams?: ExploreQueryParams & { [key: string]: string };
urlParams?: ExploreQueryParams;
prevUsedDatasource?: { orgId: number; datasource: string };
mixedEnabled?: boolean;
};
@ -166,7 +166,7 @@ export function setupExplore(options?: SetupOptions): {
};
}
function makeDatasourceSetup({
export function makeDatasourceSetup({
name = 'loki',
id = 1,
uid: uidOverride,
@ -232,10 +232,10 @@ function makeDatasourceSetup({
};
}
export const waitForExplore = (exploreId: ExploreId = ExploreId.left) => {
export const waitForExplore = (exploreId = 'left') => {
return waitFor(async () => {
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();
};
export const withinExplore = (exploreId: ExploreId) => {
export const withinExplore = (exploreId: string) => {
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 { locationService } from '@grafana/runtime';
import { makeLogsQueryResponse, makeMetricsQueryResponse } from './helper/query';
import { makeLogsQueryResponse } from './helper/query';
import { setupExplore, tearDown, waitForExplore } from './helper/setup';
describe('Explore: handle running/not running query', () => {
afterEach(() => {
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();
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();
});
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 = {
left: serializeStateToUrlParam({
datasource: 'loki-uid',
@ -46,61 +36,10 @@ describe('Explore: handle running/not running query', () => {
// And that the editor gets the expr from the url
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
expect(datasources.loki.query).toBeCalledTimes(1);
expect(jest.mocked(datasources.loki.query).mock.calls[0][0]).toMatchObject({
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 { config } from '@grafana/runtime';
import { ExploreId } from 'app/types';
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput';
@ -155,26 +154,26 @@ describe('Explore: Query History', () => {
const { datasources } = setupExplore({ urlParams });
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
await waitForExplore();
await waitForExplore(ExploreId.right);
await waitForExplore('right');
// queries in history
await openQueryHistory(ExploreId.left);
await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}'], ExploreId.left);
await openQueryHistory(ExploreId.right);
await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}'], ExploreId.right);
await openQueryHistory('left');
await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}'], 'left');
await openQueryHistory('right');
await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}'], 'right');
// star one one query
await starQueryHistory(1, ExploreId.left);
await assertQueryHistoryIsStarred([false, true], ExploreId.left);
await assertQueryHistoryIsStarred([false, true], ExploreId.right);
await starQueryHistory(1, 'left');
await assertQueryHistoryIsStarred([false, true], 'left');
await assertQueryHistoryIsStarred([false, true], 'right');
expect(reportInteractionMock).toBeCalledWith('grafana_explore_query_history_starred', {
queryHistoryEnabled: false,
newValue: true,
});
await deleteQueryHistory(0, ExploreId.left);
await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.left);
await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.right);
await deleteQueryHistory(0, 'left');
await assertQueryHistory(['{"expr":"query #1"}'], 'left');
await assertQueryHistory(['{"expr":"query #1"}'], 'right');
expect(reportInteractionMock).toBeCalledWith('grafana_explore_query_history_deleted', {
queryHistoryEnabled: false,
});
@ -193,10 +192,10 @@ describe('Explore: Query History', () => {
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
await waitForExplore();
await openQueryHistory();
await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.left);
await assertQueryHistory(['{"expr":"query #1"}'], 'left');
await commentQueryHistory(0, 'test comment');
await assertQueryHistoryComment(['test comment'], ExploreId.left);
await assertQueryHistoryComment(['test comment'], 'left');
});
it('updates query history settings', async () => {

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ import {
updateRichHistorySettings,
updateStarredInRichHistory,
} 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 { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes';
@ -24,13 +24,14 @@ import {
richHistoryStorageFullAction,
richHistoryUpdatedAction,
} from './main';
import { selectPanesEntries } from './selectors';
//
// Actions and Payloads
//
export interface HistoryUpdatedPayload {
exploreId: ExploreId;
exploreId: string;
history: HistoryItem[];
}
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]) => {
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> => {
return async (dispatch) => {
return async (dispatch, getState) => {
await deleteAllFromRichHistory();
dispatch(
richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId: ExploreId.left })
);
dispatch(
richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId: ExploreId.right })
);
selectPanesEntries(getState()).forEach(([exploreId]) => {
dispatch(richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId }));
dispatch(richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId }));
});
};
};
export const loadRichHistory = (exploreId: ExploreId): ThunkResult<void> => {
export const loadRichHistory = (exploreId: string): ThunkResult<void> => {
return async (dispatch, getState) => {
const filters = getState().explore.panes[exploreId]!.richHistorySearchFilters;
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) => {
const currentFilters = getState().explore.panes[exploreId]?.richHistorySearchFilters;
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) => {
dispatch(richHistorySearchFiltersUpdatedAction({ filters: undefined, 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
*/
export const updateHistorySearchFilters = (
exploreId: ExploreId,
filters: RichHistorySearchFilters
): ThunkResult<void> => {
export const updateHistorySearchFilters = (exploreId: string, filters: RichHistorySearchFilters): ThunkResult<void> => {
return async (dispatch, getState) => {
await dispatch(richHistorySearchFiltersUpdatedAction({ exploreId, filters: { ...filters } }));
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 { MockDataSourceApi } from '../../../../test/mocks/datasource_srv';
import { ExploreId, ExploreItemState, ExploreState } from '../../../types';
import { ExploreItemState, ExploreState } from '../../../types';
import { exploreReducer, navigateToExplore, splitClose } from './main';
@ -117,36 +117,6 @@ describe('navigateToExplore', () => {
describe('Explore reducer', () => {
describe('split view', () => {
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', () => {
const leftItemMock = {
containerWidth: 100,
@ -166,7 +136,7 @@ describe('Explore reducer', () => {
// closing left item
reducerTester<ExploreState>()
.givenReducer(exploreReducer, initialState)
.whenActionIsDispatched(splitClose(ExploreId.right))
.whenActionIsDispatched(splitClose('right'))
.thenStateShouldEqual({
evenSplitPanes: true,
largerExploreId: undefined,
@ -193,7 +163,7 @@ describe('Explore reducer', () => {
reducerTester<ExploreState>()
.givenReducer(exploreReducer, initialState)
.whenActionIsDispatched(splitClose(ExploreId.right))
.whenActionIsDispatched(splitClose('right'))
.thenStateShouldEqual({
evenSplitPanes: true,
panes: {

View File

@ -3,9 +3,9 @@ import { AnyAction } from 'redux';
import { SplitOpenOptions } from '@grafana/data';
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 { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore';
import { ExploreItemState, ExploreState } from 'app/types/explore';
import { RichHistoryResults } from '../../../core/history/RichHistoryStorage';
import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes';
@ -26,7 +26,7 @@ export interface SyncTimesPayload {
}
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'
);
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 richHistorySearchFiltersUpdatedAction = createAction<{
exploreId: ExploreId;
exploreId: string;
filters?: RichHistorySearchFilters;
}>('explore/richHistorySearchFiltersUpdatedAction');
export const saveCorrelationsAction = createAction<CorrelationData[]>('explore/saveCorrelationsAction');
export const splitSizeUpdateAction = createAction<{
largerExploreId?: ExploreId;
largerExploreId?: string;
}>('explore/splitSizeUpdateAction');
export const maximizePaneAction = createAction<{
exploreId?: ExploreId;
exploreId?: string;
}>('explore/maximizePaneAction');
export const evenPaneResizeAction = createAction('explore/evenPaneResizeAction');
@ -53,8 +53,7 @@ export const evenPaneResizeAction = createAction('explore/evenPaneResizeAction')
/**
* Close the pane with the given id.
*/
type SplitCloseActionPayload = ExploreId;
export const splitClose = createAction<SplitCloseActionPayload>('explore/splitClose');
export const splitClose = createAction<string>('explore/splitClose');
export interface SetPaneStateActionPayload {
[itemId: string]: Partial<ExploreItemState>;
@ -64,27 +63,31 @@ export const setPaneState = createAction<SetPaneStateActionPayload>('explore/set
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.
*
* TODO: this can be improved by better inferring fallback values.
*/
export const splitOpen = createAsyncThunk(
'explore/splitOpen',
async (options: SplitOpenOptions | undefined, { getState, dispatch }) => {
const leftState = getState().explore.panes.left;
async (options: SplitOpenOptions | undefined, { getState, dispatch, requestId }) => {
// 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(
initializeExplore({
exploreId: ExploreId.right,
datasource: options?.datasourceUid || leftState?.datasourceInstance?.getRef(),
exploreId: requestId,
datasource: options?.datasourceUid || originState?.datasourceInstance?.getRef(),
queries: withUniqueRefIds(queries),
range: options?.range || leftState?.range.raw || DEFAULT_RANGE,
panelsState: options?.panelsState || leftState?.panelsState,
range: options?.range || originState?.range.raw || DEFAULT_RANGE,
panelsState: options?.panelsState || originState?.panelsState,
})
);
},
{
idGenerator: generateExploreId,
}
);
@ -138,9 +141,8 @@ export const initialExploreState: ExploreState = {
*/
export const exploreReducer = (state = initialExploreState, action: AnyAction): ExploreState => {
if (splitClose.match(action)) {
const panes = {
left: action.payload === ExploreId.left ? state.panes.right : state.panes.left,
};
const { [action.payload]: _, ...panes } = { ...state.panes };
return {
...state,
panes,
@ -218,18 +220,23 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction):
...state,
panes: {
...state.panes,
right: initialExploreItemState,
[action.meta.requestId]: initialExploreItemState,
},
};
}
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 {
...state,
panes: {
...state.panes,
[action.meta.arg.exploreId]: initialExploreItemState,
},
panes,
};
}
@ -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') {
return {
...state,
panes: Object.entries(state.panes).reduce<ExploreState['panes']>((acc, [id, pane]) => {
if (id === exploreId) {
acc[id] = paneReducer(pane, action);
} else {
acc[id as ExploreId] = pane;
}
return acc;
panes: Object.entries(state.panes).reduce((acc, [id, pane]) => {
return {
...acc,
[id]: id === exploreId ? paneReducer(pane, action) : pane,
};
}, {}),
};
}

View File

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

View File

@ -47,7 +47,7 @@ import {
ThunkDispatch,
ThunkResult,
} 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 { 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
*/
export const selectIsWaitingForData = (exploreId: ExploreId) => {
export const selectIsWaitingForData = (exploreId: string) => {
return (state: StoreState) => {
const panelState = state.explore.panes[exploreId];
if (!panelState) {
@ -83,7 +83,7 @@ export const selectIsWaitingForData = (exploreId: ExploreId) => {
* Adds a query row after the row with the given index.
*/
export interface AddQueryRowPayload {
exploreId: ExploreId;
exploreId: string;
index: number;
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.
*/
export interface ChangeQueriesPayload {
exploreId: ExploreId;
exploreId: string;
queries: DataQuery[];
}
export const changeQueriesAction = createAction<ChangeQueriesPayload>('explore/changeQueries');
@ -103,18 +103,18 @@ export const changeQueriesAction = createAction<ChangeQueriesPayload>('explore/c
* Cancel running queries.
*/
export interface CancelQueriesPayload {
exploreId: ExploreId;
exploreId: string;
}
export const cancelQueriesAction = createAction<CancelQueriesPayload>('explore/cancelQueries');
export interface QueriesImportedPayload {
exploreId: ExploreId;
exploreId: string;
queries: DataQuery[];
}
export const queriesImportedAction = createAction<QueriesImportedPayload>('explore/queriesImported');
export interface QueryStoreSubscriptionPayload {
exploreId: ExploreId;
exploreId: string;
querySubscription: Unsubscribable;
}
@ -123,19 +123,19 @@ export const queryStoreSubscriptionAction = createAction<QueryStoreSubscriptionP
);
const setSupplementaryQueryEnabledAction = createAction<{
exploreId: ExploreId;
exploreId: string;
type: SupplementaryQueryType;
enabled: boolean;
}>('explore/setSupplementaryQueryEnabledAction');
export interface StoreSupplementaryQueryDataProvider {
exploreId: ExploreId;
exploreId: string;
dataProvider?: Observable<DataQueryResponse>;
type: SupplementaryQueryType;
}
export interface CleanSupplementaryQueryDataProvider {
exploreId: ExploreId;
exploreId: string;
type: SupplementaryQueryType;
}
@ -150,12 +150,12 @@ export const cleanSupplementaryQueryDataProviderAction = createAction<CleanSuppl
'explore/cleanSupplementaryQueryDataProviderAction'
);
export const cleanSupplementaryQueryAction = createAction<{ exploreId: ExploreId; type: SupplementaryQueryType }>(
export const cleanSupplementaryQueryAction = createAction<{ exploreId: string; type: SupplementaryQueryType }>(
'explore/cleanSupplementaryQueryAction'
);
export interface StoreSupplementaryQueryDataSubscriptionPayload {
exploreId: ExploreId;
exploreId: string;
dataSubscription?: SubscriptionLike;
type: SupplementaryQueryType;
}
@ -171,13 +171,13 @@ const storeSupplementaryQueryDataSubscriptionAction = createAction<StoreSuppleme
* Stores data returned by the provider. Used internally by loadSupplementaryQueryData().
*/
const updateSupplementaryQueryDataAction = createAction<{
exploreId: ExploreId;
exploreId: string;
type: SupplementaryQueryType;
data: DataQueryResponse;
}>('explore/updateSupplementaryQueryDataAction');
export interface QueryEndedPayload {
exploreId: ExploreId;
exploreId: string;
response: ExplorePanelData;
}
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.
*/
export interface SetQueriesPayload {
exploreId: ExploreId;
exploreId: string;
queries: DataQuery[];
}
export const setQueriesAction = createAction<SetQueriesPayload>('explore/setQueries');
export interface ChangeLoadingStatePayload {
exploreId: ExploreId;
exploreId: string;
loadingState: LoadingState;
}
export const changeLoadingStateAction = createAction<ChangeLoadingStatePayload>('changeLoadingState');
export interface SetPausedStatePayload {
exploreId: ExploreId;
exploreId: string;
isPaused: boolean;
}
export const setPausedStateAction = createAction<SetPausedStatePayload>('explore/setPausedState');
export interface ClearLogsPayload {
exploreId: ExploreId;
exploreId: string;
}
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
*/
export interface ScanStartPayload {
exploreId: ExploreId;
exploreId: string;
}
export const scanStartAction = createAction<ScanStartPayload>('explore/scanStart');
@ -221,7 +221,7 @@ export const scanStartAction = createAction<ScanStartPayload>('explore/scanStart
* Stop any scanning for more results.
*/
export interface ScanStopPayload {
exploreId: ExploreId;
exploreId: string;
}
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).
*/
export interface AddResultsToCachePayload {
exploreId: ExploreId;
exploreId: string;
cacheKey: string;
queryResponse: ExplorePanelData;
}
@ -240,14 +240,14 @@ export const addResultsToCacheAction = createAction<AddResultsToCachePayload>('e
* Clears cache.
*/
export interface ClearCachePayload {
exploreId: ExploreId;
exploreId: string;
}
export const clearCacheAction = createAction<ClearCachePayload>('explore/clearCache');
/**
* 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) => {
const queries = getState().explore.panes[exploreId]!.queries;
let datasourceOverride = undefined;
@ -270,7 +270,7 @@ export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult<vo
/**
* Cancel running queries
*/
export function cancelQueries(exploreId: ExploreId): ThunkResult<void> {
export function cancelQueries(exploreId: string): ThunkResult<void> {
return (dispatch, getState) => {
dispatch(scanStopAction({ exploreId }));
dispatch(cancelQueriesAction({ exploreId }));
@ -352,7 +352,7 @@ export const changeQueries = createAsyncThunk<void, ChangeQueriesPayload>(
* @param targetDataSource
*/
export const importQueries = (
exploreId: ExploreId,
exploreId: string,
queries: DataQuery[],
sourceDataSource: DataSourceApi | undefined | null,
targetDataSource: DataSourceApi,
@ -425,7 +425,7 @@ export const importQueries = (
* @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`.
*/
export function modifyQueries(
exploreId: ExploreId,
exploreId: string,
modification: QueryFixAction,
modifier: (query: DataQuery, modification: QueryFixAction) => Promise<DataQuery>
): ThunkResult<void> {
@ -453,7 +453,7 @@ async function handleHistory(
history: Array<HistoryItem<DataQuery>>,
datasource: DataSourceApi,
queries: DataQuery[],
exploreId: ExploreId
exploreId: string
) {
const datasourceId = datasource.meta.id;
const nextHistory = updateHistory(history, datasourceId, queries);
@ -465,12 +465,12 @@ async function handleHistory(
// used filters. Instead, we refresh the query history list.
// TODO: run only if Query History list is opened (#47252)
for (const exploreId in state.panes) {
await dispatch(loadRichHistory(exploreId as ExploreId));
await dispatch(loadRichHistory(exploreId));
}
}
interface RunQueriesOptions {
exploreId: ExploreId;
exploreId: string;
preserveCache?: boolean;
}
/**
@ -653,7 +653,7 @@ const groupDataQueries = async (datasources: DataQuery[], scopedVars: ScopedVars
};
type HandleSupplementaryQueriesOptions = {
exploreId: ExploreId;
exploreId: string;
transaction: QueryTransaction;
datasourceInstance: DataSourceApi;
newQuerySource: Observable<ExplorePanelData>;
@ -772,7 +772,7 @@ function canReuseSupplementaryQueryData(
* Reset queries to the given queries. Any modifications will be discarded.
* 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) => {
// Inject react keys into query objects
const queries = getState().explore.panes[exploreId]!.queries;
@ -787,7 +787,7 @@ export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): Thunk
* @param exploreId Explore area
* @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) => {
// Register the scanner
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) => {
const queryResponse = getState().explore.panes[exploreId]!.queryResponse;
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) => {
dispatch(clearCacheAction({ exploreId }));
};
@ -821,7 +821,7 @@ export function clearCache(exploreId: ExploreId): ThunkResult<void> {
/**
* 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) => {
const { supplementaryQueries } = getState().explore.panes[exploreId]!;
const dataProvider = supplementaryQueries[type].dataProvider;
@ -844,7 +844,7 @@ export function loadSupplementaryQueryData(exploreId: ExploreId, type: Supplemen
}
export function setSupplementaryQueryEnabled(
exploreId: ExploreId,
exploreId: string,
enabled: boolean,
type: SupplementaryQueryType
): 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 { ExploreId, ExploreState, StoreState } from 'app/types';
import { ExploreItemState, StoreState } from 'app/types';
export const selectPanes = (state: Pick<StoreState, 'explore'>) => state.explore.panes;
/**
* Explore renders panes by iterating over the panes object. This selector ensures that entries in the returned panes object
* are in the correct order.
*/
export const selectOrderedExplorePanes = createSelector(selectPanes, (panes) => {
const orderedPanes: ExploreState['panes'] = {};
export const selectPanesEntries = createSelector<
[(state: Pick<StoreState, 'explore'>) => Record<string, ExploreItemState | undefined>],
Array<[string, ExploreItemState]>
>(selectPanes, Object.entries);
if (panes.left) {
orderedPanes.left = panes.left;
}
if (panes.right) {
orderedPanes.right = panes.right;
}
return orderedPanes;
});
export const isSplit = createSelector(selectPanesEntries, (panes) => panes.length > 1);
export const isSplit = createSelector(selectPanes, (panes) => Object.keys(panes).length > 1);
export const getExploreItemSelector = (exploreId: ExploreId) =>
createSelector(selectPanes, (panes) => panes[exploreId]);
export const getExploreItemSelector = (exploreId: string) => createSelector(selectPanes, (panes) => panes[exploreId]);

View File

@ -2,7 +2,7 @@ import { reducerTester } from 'test/core/redux/reducerTester';
import { dateTime } from '@grafana/data';
import { configureStore } from 'app/store/configureStore';
import { ExploreId, ExploreItemState } from 'app/types';
import { ExploreItemState } from 'app/types';
import { createDefaultInitialState } from './helpers';
import { changeRangeAction, timeReducer, updateTime } from './time';
@ -30,7 +30,7 @@ describe('Explore item reducer', () => {
describe('When time is updated', () => {
it('Time service is re-initialized and template service is updated with the new time range', async () => {
const { dispatch } = configureStore(createDefaultInitialState().defaultInitialState as any);
dispatch(updateTime({ exploreId: ExploreId.left }));
dispatch(updateTime({ exploreId: 'left' }));
expect(mockTimeSrv.init).toBeCalled();
expect(mockTemplateSrv.updateTimeRange).toBeCalledWith(MOCK_TIME_RANGE);
});
@ -46,7 +46,7 @@ describe('Explore item reducer', () => {
} as unknown as ExploreItemState)
.whenActionIsDispatched(
changeRangeAction({
exploreId: ExploreId.left,
exploreId: 'left',
absoluteRange: { from: 1546297200000, to: 1546383600000 },
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 { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors';
import { ExploreItemState, ThunkResult } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { getTimeSrv } from '../../dashboard/services/TimeSrv';
import { TimeModel } from '../../dashboard/state/TimeModel';
@ -20,7 +19,7 @@ import { runQueries } from './query';
//
export interface ChangeRangePayload {
exploreId: ExploreId;
exploreId: string;
range: TimeRange;
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.
*/
export interface ChangeRefreshIntervalPayload {
exploreId: ExploreId;
exploreId: string;
refreshInterval: string;
}
export const changeRefreshInterval = createAction<ChangeRefreshIntervalPayload>('explore/changeRefreshInterval');
export const updateTimeRange = (options: {
exploreId: ExploreId;
exploreId: string;
rawRange?: RawTimeRange;
absoluteRange?: AbsoluteTimeRange;
}): ThunkResult<void> => {
@ -44,8 +43,8 @@ export const updateTimeRange = (options: {
const { syncedTimes } = getState().explore;
if (syncedTimes) {
Object.keys(getState().explore.panes).forEach((exploreId) => {
dispatch(updateTime({ ...options, exploreId: exploreId as ExploreId }));
dispatch(runQueries({ exploreId: exploreId as ExploreId, preserveCache: true }));
dispatch(updateTime({ ...options, exploreId }));
dispatch(runQueries({ exploreId: exploreId, preserveCache: true }));
});
} else {
dispatch(updateTime({ ...options }));
@ -55,7 +54,7 @@ export const updateTimeRange = (options: {
};
export const updateTime = (config: {
exploreId: ExploreId;
exploreId: string;
rawRange?: RawTimeRange;
absoluteRange?: AbsoluteTimeRange;
}): 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.
* 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) => {
const range = getState().explore.panes[exploreId]!.range.raw;
Object.keys(getState().explore.panes)
.filter((key) => key !== exploreId)
.forEach((exploreId) => {
dispatch(updateTimeRange({ exploreId: exploreId as ExploreId, rawRange: range }));
dispatch(updateTimeRange({ exploreId, rawRange: range }));
});
const isTimeSynced = getState().explore.syncedTimes;
@ -132,7 +131,7 @@ export function makeAbsoluteTime(): ThunkResult<void> {
Object.entries(getState().explore.panes).forEach(([exploreId, exploreItemState]) => {
const range = getTimeRange(timeZone, exploreItemState!.range.raw, fiscalYearStartMonth);
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 { useDispatch } from 'app/types';
import { ExploreId } from '../../types';
import { setPausedStateAction, runQueries, clearLogs } from './state/query';
import { changeRefreshInterval } from './state/time';
/**
* 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 pause = useCallback(() => {
@ -52,7 +50,7 @@ export function useLiveTailControls(exploreId: ExploreId) {
}
type Props = {
exploreId: ExploreId;
exploreId: string;
children: (controls: ReturnType<typeof useLiveTailControls>) => React.ReactElement;
};

View File

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

View File

@ -15,21 +15,13 @@ import {
DataQueryResponse,
ExplorePanelsState,
SupplementaryQueryType,
UrlQueryMap,
} from '@grafana/data';
import { RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistoryTypes';
import { CorrelationData } from '../features/correlations/useCorrelations';
export enum ExploreId {
left = 'left',
right = 'right',
}
export type ExploreQueryParams = {
left?: string;
right?: string;
};
export type ExploreQueryParams = UrlQueryMap;
/**
* Global Explore state
*/
@ -39,13 +31,7 @@ export interface ExploreState {
*/
syncedTimes: boolean;
// This being optional wouldn't be needed with noUncheckedIndexedAccess set to true, but it cause more than 5k errors currently.
// 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;
};
panes: Record<string, ExploreItemState | undefined>;
correlations?: CorrelationData[];

View File

@ -20,12 +20,17 @@ export interface Props {
* Wrapps component in redux store provider, Router and GrafanaContext
*/
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 (
<Provider store={store}>
<Router history={locationService.getHistory()}>
<GrafanaContext.Provider value={grafanaContext}>{children}</GrafanaContext.Provider>
<GrafanaContext.Provider value={context}>{children}</GrafanaContext.Provider>
</Router>
</Provider>
);