Chore: Split Explore redux code into multiple sections (#28819)

* Split main reducer from item reducer

* Move query related redux to separate file

* Split more parts and tests

* Fix import

* Remove unused code

* Update public/app/features/explore/state/datasource.ts

Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>

* Add section comments

* Rename ExploreItem to ExplorePane

* Fix imports

Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>
This commit is contained in:
Andrej Ocenas
2020-11-09 14:48:24 +01:00
committed by GitHub
parent 8499d1408d
commit 5bed54170e
32 changed files with 2537 additions and 2616 deletions

View File

@@ -6,7 +6,7 @@ import teamsReducers from 'app/features/teams/state/reducers';
import apiKeysReducers from 'app/features/api-keys/state/reducers';
import foldersReducers from 'app/features/folders/state/reducers';
import dashboardReducers from 'app/features/dashboard/state/reducers';
import exploreReducers from 'app/features/explore/state/reducers';
import exploreReducers from 'app/features/explore/state/main';
import pluginReducers from 'app/features/plugins/state/reducers';
import dataSourcesReducers from 'app/features/datasources/state/reducers';
import usersReducers from 'app/features/users/state/reducers';

View File

@@ -4,7 +4,7 @@ import { getPanelMenu } from './getPanelMenu';
import { describe } from '../../../../test/lib/common';
import { setStore } from 'app/store/store';
import config from 'app/core/config';
import * as actions from 'app/features/explore/state/actions';
import * as actions from 'app/features/explore/state/main';
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {

View File

@@ -6,7 +6,7 @@ import { copyPanel, duplicatePanel, removePanel, sharePanel } from 'app/features
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { contextSrv } from '../../../core/services/context_srv';
import { navigateToExplore } from '../../explore/state/actions';
import { navigateToExplore } from '../../explore/state/main';
import { getExploreUrl } from '../../../core/utils/explore';
import { getTimeSrv } from '../services/TimeSrv';
import { PanelCtrl } from '../../panel/panel_ctrl';

View File

@@ -4,7 +4,7 @@ import { getFirstNonQueryRowSpecificError } from 'app/core/utils/explore';
import { ExploreId } from 'app/types/explore';
import { shallow } from 'enzyme';
import { Explore, ExploreProps } from './Explore';
import { scanStopAction } from './state/actionTypes';
import { scanStopAction } from './state/query';
import { SecondaryActions } from './SecondaryActions';
import { getTheme } from '@grafana/ui';

View File

@@ -30,18 +30,10 @@ import QueryRows from './QueryRows';
import TableContainer from './TableContainer';
import RichHistoryContainer from './RichHistory/RichHistoryContainer';
import ExploreQueryInspector from './ExploreQueryInspector';
import {
addQueryRow,
changeSize,
initializeExplore,
modifyQueries,
refreshExplore,
scanStart,
setQueries,
updateTimeRange,
splitOpen,
} from './state/actions';
import { splitOpen } from './state/main';
import { changeSize, initializeExplore, refreshExplore } from './state/explorePane';
import { updateTimeRange } from './state/time';
import { scanStopAction, addQueryRow, modifyQueries, setQueries, scanStart } from './state/query';
import { ExploreId, ExploreItemState, ExploreUpdateState } from 'app/types/explore';
import { StoreState } from 'app/types';
import {
@@ -56,7 +48,6 @@ import { ExploreToolbar } from './ExploreToolbar';
import { NoDataSourceCallToAction } from './NoDataSourceCallToAction';
import { getTimeZone } from '../profile/state/selectors';
import { ErrorContainer } from './ErrorContainer';
import { scanStopAction } from './state/actionTypes';
import { ExploreGraphPanel } from './ExploreGraphPanel';
//TODO:unification
import { TraceView } from './TraceView/TraceView';

View File

@@ -11,16 +11,9 @@ import { DataQuery, RawTimeRange, TimeRange, TimeZone } from '@grafana/data';
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { StoreState } from 'app/types/store';
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
import {
cancelQueries,
changeDatasource,
changeRefreshInterval,
clearQueries,
runQueries,
splitClose,
splitOpen,
syncTimes,
} from './state/actions';
import { changeDatasource } from './state/datasource';
import { splitClose, splitOpen } from './state/main';
import { syncTimes, changeRefreshInterval } from './state/time';
import { updateLocation } from 'app/core/actions';
import { getTimeZone } from '../profile/state/selectors';
import { updateTimeZoneForSession } from '../profile/state/reducers';
@@ -33,6 +26,7 @@ import { RunButton } from './RunButton';
import { LiveTailControls } from './useLiveTailControls';
import { getExploreDatasources } from './state/selectors';
import { setDashboardQueriesToUpdateOnLoad } from '../dashboard/state/reducers';
import { cancelQueries, clearQueries, runQueries } from './state/query';
const { ButtonSelect } = LegacyForms;

View File

@@ -20,9 +20,10 @@ import {
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { StoreState } from 'app/types';
import { changeDedupStrategy, splitOpen, updateTimeRange } from './state/actions';
import { toggleLogLevelAction } from 'app/features/explore/state/actionTypes';
import { deduplicatedRowsSelector } from 'app/features/explore/state/selectors';
import { splitOpen } from './state/main';
import { updateTimeRange } from './state/time';
import { toggleLogLevelAction, changeDedupStrategy } from './state/explorePane';
import { deduplicatedRowsSelector } from './state/selectors';
import { getTimeZone } from '../profile/state/selectors';
import { LiveLogsWithTheme } from './LiveLogs';
import { Logs } from './Logs';

View File

@@ -8,8 +8,6 @@ import { connect } from 'react-redux';
// Components
import AngularQueryEditor from './QueryEditor';
import { QueryRowActions } from './QueryRowActions';
// Actions
import { changeQuery, modifyQueries, runQueries } from './state/actions';
// Types
import { StoreState } from 'app/types';
import {
@@ -25,8 +23,9 @@ import {
import { selectors } from '@grafana/e2e-selectors';
import { ExploreItemState, ExploreId } from 'app/types/explore';
import { highlightLogsExpressionAction, removeQueryRowAction } from './state/actionTypes';
import { highlightLogsExpressionAction } from './state/explorePane';
import { ErrorContainer } from './ErrorContainer';
import { changeQuery, modifyQueries, removeQueryRowAction, runQueries } from './state/query';
interface PropsFromParent {
exploreId: ExploreId;

View File

@@ -5,14 +5,17 @@ import { css, cx } from 'emotion';
import { stylesFactory, useTheme, TextArea, Button, IconButton } from '@grafana/ui';
import { getDataSourceSrv } from '@grafana/runtime';
import { GrafanaTheme, AppEvents, DataSourceApi } from '@grafana/data';
import { RichHistoryQuery, ExploreId } from 'app/types/explore';
import { RichHistoryQuery, ExploreId, ExploreItemState } from 'app/types/explore';
import { createUrlFromRichHistory, createQueryText } from 'app/core/utils/richHistory';
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
import { copyStringToClipboard } from 'app/core/utils/explore';
import appEvents from 'app/core/app_events';
import { StoreState, CoreEvents } from 'app/types';
import { changeDatasource, updateRichHistory, setQueries } from '../state/actions';
import { updateRichHistory } from '../state/history';
import { changeDatasource } from '../state/datasource';
import { setQueries } from '../state/query';
export interface Props {
query: RichHistoryQuery;
dsImg: string;

View File

@@ -8,14 +8,14 @@ import store from 'app/core/store';
import { RICH_HISTORY_SETTING_KEYS } from 'app/core/utils/richHistory';
// Types
import { StoreState } from 'app/types';
import { ExploreItemState, StoreState } from 'app/types';
import { ExploreId, RichHistoryQuery } from 'app/types/explore';
// Components, enums
import { RichHistory, Tabs } from './RichHistory';
//Actions
import { deleteRichHistory } from '../state/actions';
import { deleteRichHistory } from '../state/history';
import { ExploreDrawer } from '../ExploreDrawer';
export interface Props {

View File

@@ -5,7 +5,7 @@ import { DataFrame, TimeRange, ValueLinkConfig } from '@grafana/data';
import { Collapse, Table } from '@grafana/ui';
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { StoreState } from 'app/types';
import { splitOpen } from './state/actions';
import { splitOpen } from './state/main';
import { config } from 'app/core/config';
import { PANEL_BORDER } from 'app/core/constants';
import { MetaInfoText } from './MetaInfoText';

View File

@@ -6,7 +6,7 @@ import { StoreState } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { CustomScrollbar, ErrorBoundaryAlert } from '@grafana/ui';
import { resetExploreAction } from './state/actionTypes';
import { resetExploreAction } from './state/main';
import Explore from './Explore';
interface WrapperProps {

View File

@@ -1,313 +0,0 @@
// Types
import { Unsubscribable } from 'rxjs';
import { createAction } from '@reduxjs/toolkit';
import {
AbsoluteTimeRange,
DataQuery,
DataSourceApi,
HistoryItem,
LoadingState,
LogLevel,
LogsDedupStrategy,
QueryFixAction,
TimeRange,
EventBusExtended,
} from '@grafana/data';
import { ExploreId, ExploreItemState, ExplorePanelData } from 'app/types/explore';
export interface AddQueryRowPayload {
exploreId: ExploreId;
index: number;
query: DataQuery;
}
export interface ChangeQueryPayload {
exploreId: ExploreId;
query: DataQuery;
index: number;
override: boolean;
}
export interface ChangeSizePayload {
exploreId: ExploreId;
width: number;
height: number;
}
export interface ChangeRefreshIntervalPayload {
exploreId: ExploreId;
refreshInterval: string;
}
export interface ClearQueriesPayload {
exploreId: ExploreId;
}
export interface HighlightLogsExpressionPayload {
exploreId: ExploreId;
expressions: string[];
}
export interface InitializeExplorePayload {
exploreId: ExploreId;
containerWidth: number;
eventBridge: EventBusExtended;
queries: DataQuery[];
range: TimeRange;
originPanelId?: number | null;
}
export interface LoadDatasourceMissingPayload {
exploreId: ExploreId;
}
export interface LoadDatasourcePendingPayload {
exploreId: ExploreId;
requestedDatasourceName: string;
}
export interface LoadDatasourceReadyPayload {
exploreId: ExploreId;
history: HistoryItem[];
}
export interface ModifyQueriesPayload {
exploreId: ExploreId;
modification: QueryFixAction;
index?: number;
modifier: (query: DataQuery, modification: QueryFixAction) => DataQuery;
}
export interface QueryEndedPayload {
exploreId: ExploreId;
response: ExplorePanelData;
}
export interface QueryStoreSubscriptionPayload {
exploreId: ExploreId;
querySubscription: Unsubscribable;
}
export interface HistoryUpdatedPayload {
exploreId: ExploreId;
history: HistoryItem[];
}
export interface RemoveQueryRowPayload {
exploreId: ExploreId;
index: number;
}
export interface ScanStartPayload {
exploreId: ExploreId;
}
export interface ScanStopPayload {
exploreId: ExploreId;
}
export interface SetQueriesPayload {
exploreId: ExploreId;
queries: DataQuery[];
}
export interface SplitCloseActionPayload {
itemId: ExploreId;
}
export interface SplitOpenPayload {
itemState: ExploreItemState;
}
export interface SyncTimesPayload {
syncedTimes: boolean;
}
export interface UpdateDatasourceInstancePayload {
exploreId: ExploreId;
datasourceInstance: DataSourceApi;
}
export interface ToggleLogLevelPayload {
exploreId: ExploreId;
hiddenLogLevels: LogLevel[];
}
export interface QueriesImportedPayload {
exploreId: ExploreId;
queries: DataQuery[];
}
export interface SetUrlReplacedPayload {
exploreId: ExploreId;
}
export interface ChangeRangePayload {
exploreId: ExploreId;
range: TimeRange;
absoluteRange: AbsoluteTimeRange;
}
export interface ChangeLoadingStatePayload {
exploreId: ExploreId;
loadingState: LoadingState;
}
export interface SetPausedStatePayload {
exploreId: ExploreId;
isPaused: boolean;
}
export interface ResetExplorePayload {
force?: boolean;
}
export interface ChangeDedupStrategyPayload {
exploreId: ExploreId;
dedupStrategy: LogsDedupStrategy;
}
/**
* Adds a query row after the row with the given index.
*/
export const addQueryRowAction = createAction<AddQueryRowPayload>('explore/addQueryRow');
/**
* Query change handler for the query row with the given index.
* If `override` is reset the query modifications and run the queries. Use this to set queries via a link.
*/
export const changeQueryAction = createAction<ChangeQueryPayload>('explore/changeQuery');
/**
* Keep track of the Explore container size, in particular the width.
* The width will be used to calculate graph intervals (number of datapoints).
*/
export const changeSizeAction = createAction<ChangeSizePayload>('explore/changeSize');
/**
* Change the time range of Explore. Usually called from the Timepicker or a graph interaction.
*/
export const changeRefreshIntervalAction = createAction<ChangeRefreshIntervalPayload>('explore/changeRefreshInterval');
/**
* Change deduplication strategy for logs.
*/
export const changeDedupStrategyAction = createAction<ChangeDedupStrategyPayload>('explore/changeDedupStrategyAction');
/**
* Clear all queries and results.
*/
export const clearQueriesAction = createAction<ClearQueriesPayload>('explore/clearQueries');
/**
* Cancel running queries.
*/
export const cancelQueriesAction = createAction<ClearQueriesPayload>('explore/cancelQueries');
/**
* Highlight expressions in the log results
*/
export const highlightLogsExpressionAction = createAction<HighlightLogsExpressionPayload>(
'explore/highlightLogsExpression'
);
/**
* Initialize Explore state with state from the URL and the React component.
* Call this only on components for with the Explore state has not been initialized.
*/
export const initializeExploreAction = createAction<InitializeExplorePayload>('explore/initializeExplore');
/**
* Display an error when no datasources have been configured
*/
export const loadDatasourceMissingAction = createAction<LoadDatasourceMissingPayload>('explore/loadDatasourceMissing');
/**
* Start the async process of loading a datasource to display a loading indicator
*/
export const loadDatasourcePendingAction = createAction<LoadDatasourcePendingPayload>('explore/loadDatasourcePending');
/**
* Datasource loading was completed.
*/
export const loadDatasourceReadyAction = createAction<LoadDatasourceReadyPayload>('explore/loadDatasourceReady');
/**
* Action to modify a query given a datasource-specific modifier action.
* @param exploreId Explore area
* @param modification Action object with a type, e.g., ADD_FILTER
* @param index Optional query row index. If omitted, the modification is applied to all query rows.
* @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`.
*/
export const modifyQueriesAction = createAction<ModifyQueriesPayload>('explore/modifyQueries');
export const queryStreamUpdatedAction = createAction<QueryEndedPayload>('explore/queryStreamUpdated');
export const queryStoreSubscriptionAction = createAction<QueryStoreSubscriptionPayload>(
'explore/queryStoreSubscription'
);
/**
* Remove query row of the given index, as well as associated query results.
*/
export const removeQueryRowAction = createAction<RemoveQueryRowPayload>('explore/removeQueryRow');
/**
* Start a scan for more results using the given scanner.
* @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 const scanStartAction = createAction<ScanStartPayload>('explore/scanStart');
/**
* Stop any scanning for more results.
*/
export const scanStopAction = createAction<ScanStopPayload>('explore/scanStop');
/**
* Reset queries to the given queries. Any modifications will be discarded.
* Use this action for clicks on query examples. Triggers a query run.
*/
export const setQueriesAction = createAction<SetQueriesPayload>('explore/setQueries');
/**
* Close the split view and save URL state.
*/
export const splitCloseAction = createAction<SplitCloseActionPayload>('explore/splitClose');
/**
* Open the split view and copy the left state to be the right state.
* The right state is automatically initialized.
* The copy keeps all query modifications but wipes the query results.
*/
export const splitOpenAction = createAction<SplitOpenPayload>('explore/splitOpen');
export const syncTimesAction = createAction<SyncTimesPayload>('explore/syncTimes');
export const richHistoryUpdatedAction = createAction<any>('explore/richHistoryUpdated');
/**
* Updates datasource instance before datasouce loading has started
*/
export const updateDatasourceInstanceAction = createAction<UpdateDatasourceInstancePayload>(
'explore/updateDatasourceInstance'
);
export const toggleLogLevelAction = createAction<ToggleLogLevelPayload>('explore/toggleLogLevel');
/**
* Resets state for explore.
*/
export const resetExploreAction = createAction<ResetExplorePayload>('explore/resetExplore');
export const queriesImportedAction = createAction<QueriesImportedPayload>('explore/queriesImported');
export const historyUpdatedAction = createAction<HistoryUpdatedPayload>('explore/historyUpdated');
export const setUrlReplacedAction = createAction<SetUrlReplacedPayload>('explore/setUrlReplaced');
export const changeRangeAction = createAction<ChangeRangePayload>('explore/changeRange');
export const changeLoadingStateAction = createAction<ChangeLoadingStatePayload>('changeLoadingState');
export const setPausedStateAction = createAction<SetPausedStatePayload>('explore/setPausedState');

View File

@@ -1,374 +0,0 @@
import { PayloadAction } from '@reduxjs/toolkit';
import { DataQuery, DefaultTimeZone, toUtc, ExploreUrlState, EventBusExtended } from '@grafana/data';
import { cancelQueries, loadDatasource, navigateToExplore, refreshExplore } from './actions';
import { ExploreId, ExploreUpdateState } from 'app/types';
import { thunkTester } from 'test/core/thunk/thunkTester';
import {
cancelQueriesAction,
initializeExploreAction,
InitializeExplorePayload,
loadDatasourcePendingAction,
loadDatasourceReadyAction,
scanStopAction,
setQueriesAction,
} from './actionTypes';
import { makeInitialUpdateState } from './reducers';
import { PanelModel } from 'app/features/dashboard/state';
import { updateLocation } from '../../../core/actions';
import { MockDataSourceApi } from '../../../../test/mocks/datasource_srv';
import * as DatasourceSrv from 'app/features/plugins/datasource_srv';
import { interval } from 'rxjs';
jest.mock('app/features/plugins/datasource_srv');
const getDatasourceSrvMock = (DatasourceSrv.getDatasourceSrv as any) as jest.Mock<DatasourceSrv.DatasourceSrv>;
beforeEach(() => {
getDatasourceSrvMock.mockClear();
getDatasourceSrvMock.mockImplementation(
() =>
({
getExternal: jest.fn().mockReturnValue([]),
get: jest.fn().mockReturnValue({
testDatasource: jest.fn(),
init: jest.fn(),
}),
} as any)
);
});
jest.mock('../../dashboard/services/TimeSrv', () => ({
getTimeSrv: jest.fn().mockReturnValue({
init: jest.fn(),
}),
}));
const t = toUtc();
const testRange = {
from: t,
to: t,
raw: {
from: t,
to: t,
},
};
jest.mock('app/core/utils/explore', () => ({
...((jest.requireActual('app/core/utils/explore') as unknown) as object),
getTimeRangeFromUrl: (range: any) => testRange,
}));
const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
const exploreId = ExploreId.left;
const containerWidth = 1920;
const eventBridge = {} as EventBusExtended;
const timeZone = DefaultTimeZone;
const range = testRange;
const urlState: ExploreUrlState = {
datasource: 'some-datasource',
queries: [],
range: range.raw,
};
const updateDefaults = makeInitialUpdateState();
const update = { ...updateDefaults, ...updateOverides };
const initialState = {
user: {
orgId: '1',
timeZone,
},
explore: {
[exploreId]: {
initialized: true,
urlState,
containerWidth,
eventBridge,
update,
datasourceInstance: { name: 'some-datasource' },
queries: [] as DataQuery[],
range,
refreshInterval: {
label: 'Off',
value: 0,
},
},
},
};
return {
initialState,
exploreId,
range,
containerWidth,
eventBridge,
};
};
describe('refreshExplore', () => {
describe('when explore is initialized', () => {
describe('and update datasource is set', () => {
it('then it should dispatch initializeExplore', async () => {
const { exploreId, initialState, containerWidth, eventBridge } = setup({ datasource: true });
const dispatchedActions = await thunkTester(initialState)
.givenThunk(refreshExplore)
.whenThunkIsDispatched(exploreId);
const initializeExplore = dispatchedActions[1] as PayloadAction<InitializeExplorePayload>;
const { type, payload } = initializeExplore;
expect(type).toEqual(initializeExploreAction.type);
expect(payload.containerWidth).toEqual(containerWidth);
expect(payload.eventBridge).toEqual(eventBridge);
expect(payload.queries.length).toBe(1); // Queries have generated keys hard to expect on
expect(payload.range.from).toEqual(testRange.from);
expect(payload.range.to).toEqual(testRange.to);
expect(payload.range.raw.from).toEqual(testRange.raw.from);
expect(payload.range.raw.to).toEqual(testRange.raw.to);
});
});
describe('and update queries is set', () => {
it('then it should dispatch setQueriesAction', async () => {
const { exploreId, initialState } = setup({ queries: true });
const dispatchedActions = await thunkTester(initialState)
.givenThunk(refreshExplore)
.whenThunkIsDispatched(exploreId);
expect(dispatchedActions[0].type).toEqual(setQueriesAction.type);
expect(dispatchedActions[0].payload).toEqual({ exploreId, queries: [] });
});
});
});
describe('when update is not initialized', () => {
it('then it should not dispatch any actions', async () => {
const exploreId = ExploreId.left;
const initialState = { explore: { [exploreId]: { initialized: false } } };
const dispatchedActions = await thunkTester(initialState)
.givenThunk(refreshExplore)
.whenThunkIsDispatched(exploreId);
expect(dispatchedActions).toEqual([]);
});
});
});
describe('running queries', () => {
it('should cancel running query when cancelQueries is dispatched', async () => {
const unsubscribable = interval(1000);
unsubscribable.subscribe();
const exploreId = ExploreId.left;
const initialState = {
explore: {
[exploreId]: {
datasourceInstance: 'test-datasource',
initialized: true,
loading: true,
querySubscription: unsubscribable,
queries: ['A'],
range: testRange,
},
},
user: {
orgId: 'A',
},
};
const dispatchedActions = await thunkTester(initialState)
.givenThunk(cancelQueries)
.whenThunkIsDispatched(exploreId);
expect(dispatchedActions).toEqual([
scanStopAction({ exploreId }),
cancelQueriesAction({ exploreId }),
expect.anything(),
]);
});
});
describe('loading datasource', () => {
describe('when loadDatasource thunk is dispatched', () => {
describe('and all goes fine', () => {
it('then it should dispatch correct actions', async () => {
const exploreId = ExploreId.left;
const name = 'some-datasource';
const initialState = { explore: { [exploreId]: { requestedDatasourceName: name } } };
const mockDatasourceInstance = {
testDatasource: () => {
return Promise.resolve({ status: 'success' });
},
name,
init: jest.fn(),
meta: { id: 'some id' },
};
const dispatchedActions = await thunkTester(initialState)
.givenThunk(loadDatasource)
.whenThunkIsDispatched(exploreId, mockDatasourceInstance);
expect(dispatchedActions).toEqual([
loadDatasourcePendingAction({
exploreId,
requestedDatasourceName: mockDatasourceInstance.name,
}),
loadDatasourceReadyAction({ exploreId, history: [] }),
]);
});
});
describe('and user changes datasource during load', () => {
it('then it should dispatch correct actions', async () => {
const exploreId = ExploreId.left;
const name = 'some-datasource';
const initialState = { explore: { [exploreId]: { requestedDatasourceName: 'some-other-datasource' } } };
const mockDatasourceInstance = {
testDatasource: () => {
return Promise.resolve({ status: 'success' });
},
name,
init: jest.fn(),
meta: { id: 'some id' },
};
const dispatchedActions = await thunkTester(initialState)
.givenThunk(loadDatasource)
.whenThunkIsDispatched(exploreId, mockDatasourceInstance);
expect(dispatchedActions).toEqual([
loadDatasourcePendingAction({
exploreId,
requestedDatasourceName: mockDatasourceInstance.name,
}),
]);
});
});
});
});
const getNavigateToExploreContext = async (openInNewWindow?: (url: string) => void) => {
const url = 'http://www.someurl.com';
const panel: Partial<PanelModel> = {
datasource: 'mocked datasource',
targets: [{ refId: 'A' }],
};
const datasource = new MockDataSourceApi(panel.datasource!);
const get = jest.fn().mockResolvedValue(datasource);
const getDataSourceSrv = jest.fn().mockReturnValue({ get });
const getTimeSrv = jest.fn();
const getExploreUrl = jest.fn().mockResolvedValue(url);
const dispatchedActions = await thunkTester({})
.givenThunk(navigateToExplore)
.whenThunkIsDispatched(panel, { getDataSourceSrv, getTimeSrv, getExploreUrl, openInNewWindow });
return {
url,
panel,
datasource,
get,
getDataSourceSrv,
getTimeSrv,
getExploreUrl,
dispatchedActions,
};
};
describe('navigateToExplore', () => {
describe('when navigateToExplore thunk is dispatched', () => {
describe('and openInNewWindow is undefined', () => {
const openInNewWindow: (url: string) => void = (undefined as unknown) as (url: string) => void;
it('then it should dispatch correct actions', async () => {
const { dispatchedActions, url } = await getNavigateToExploreContext(openInNewWindow);
expect(dispatchedActions).toEqual([updateLocation({ path: url, query: {} })]);
});
it('then getDataSourceSrv should have been once', async () => {
const { getDataSourceSrv } = await getNavigateToExploreContext(openInNewWindow);
expect(getDataSourceSrv).toHaveBeenCalledTimes(1);
});
it('then getDataSourceSrv.get should have been called with correct arguments', async () => {
const { get, panel } = await getNavigateToExploreContext(openInNewWindow);
expect(get).toHaveBeenCalledTimes(1);
expect(get).toHaveBeenCalledWith(panel.datasource);
});
it('then getTimeSrv should have been called once', async () => {
const { getTimeSrv } = await getNavigateToExploreContext(openInNewWindow);
expect(getTimeSrv).toHaveBeenCalledTimes(1);
});
it('then getExploreUrl should have been called with correct arguments', async () => {
const { getExploreUrl, panel, datasource, getDataSourceSrv, getTimeSrv } = await getNavigateToExploreContext(
openInNewWindow
);
expect(getExploreUrl).toHaveBeenCalledTimes(1);
expect(getExploreUrl).toHaveBeenCalledWith({
panel,
panelTargets: panel.targets,
panelDatasource: datasource,
datasourceSrv: getDataSourceSrv(),
timeSrv: getTimeSrv(),
});
});
});
describe('and openInNewWindow is defined', () => {
const openInNewWindow: (url: string) => void = jest.fn();
it('then it should dispatch no actions', async () => {
const { dispatchedActions } = await getNavigateToExploreContext(openInNewWindow);
expect(dispatchedActions).toEqual([]);
});
it('then getDataSourceSrv should have been once', async () => {
const { getDataSourceSrv } = await getNavigateToExploreContext(openInNewWindow);
expect(getDataSourceSrv).toHaveBeenCalledTimes(1);
});
it('then getDataSourceSrv.get should have been called with correct arguments', async () => {
const { get, panel } = await getNavigateToExploreContext(openInNewWindow);
expect(get).toHaveBeenCalledTimes(1);
expect(get).toHaveBeenCalledWith(panel.datasource);
});
it('then getTimeSrv should have been called once', async () => {
const { getTimeSrv } = await getNavigateToExploreContext(openInNewWindow);
expect(getTimeSrv).toHaveBeenCalledTimes(1);
});
it('then getExploreUrl should have been called with correct arguments', async () => {
const { getExploreUrl, panel, datasource, getDataSourceSrv, getTimeSrv } = await getNavigateToExploreContext(
openInNewWindow
);
expect(getExploreUrl).toHaveBeenCalledTimes(1);
expect(getExploreUrl).toHaveBeenCalledWith({
panel,
panelTargets: panel.targets,
panelDatasource: datasource,
datasourceSrv: getDataSourceSrv(),
timeSrv: getTimeSrv(),
});
});
it('then openInNewWindow should have been called with correct arguments', async () => {
const openInNewWindowFunc = jest.fn();
const { url } = await getNavigateToExploreContext(openInNewWindowFunc);
expect(openInNewWindowFunc).toHaveBeenCalledTimes(1);
expect(openInNewWindowFunc).toHaveBeenCalledWith(url);
});
});
});
});

View File

@@ -1,856 +0,0 @@
// Libraries
import { map, mergeMap, throttleTime } from 'rxjs/operators';
import { identity } from 'rxjs';
import { PayloadAction } from '@reduxjs/toolkit';
import { DataSourceSrv } from '@grafana/runtime';
import { RefreshPicker } from '@grafana/ui';
import {
AbsoluteTimeRange,
DataQuery,
DataSourceApi,
dateTimeForTimeZone,
ExploreUrlState,
isDateTime,
LoadingState,
LogsDedupStrategy,
PanelData,
EventBusExtended,
QueryFixAction,
RawTimeRange,
TimeRange,
} from '@grafana/data';
// Services & Utils
import store from 'app/core/store';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import {
buildQueryTransaction,
clearQueryKeys,
ensureQueries,
generateEmptyQuery,
generateNewKeyAndAddRefIdIfMissing,
GetExploreUrlArguments,
getTimeRange,
getTimeRangeFromUrl,
hasNonEmptyQuery,
lastUsedDatasourceKeyForOrgId,
parseUrlState,
stopQueryState,
updateHistory,
} from 'app/core/utils/explore';
import {
addToRichHistory,
deleteAllFromRichHistory,
deleteQueryInRichHistory,
getRichHistory,
updateCommentInRichHistory,
updateStarredInRichHistory,
} from 'app/core/utils/richHistory';
// Types
import { ThunkResult } from 'app/types';
import { ExploreId, ExploreItemState, QueryOptions } from 'app/types/explore';
import {
addQueryRowAction,
cancelQueriesAction,
changeDedupStrategyAction,
ChangeDedupStrategyPayload,
changeLoadingStateAction,
changeQueryAction,
changeRangeAction,
changeRefreshIntervalAction,
ChangeRefreshIntervalPayload,
changeSizeAction,
ChangeSizePayload,
clearQueriesAction,
historyUpdatedAction,
initializeExploreAction,
loadDatasourceMissingAction,
loadDatasourcePendingAction,
loadDatasourceReadyAction,
LoadDatasourceReadyPayload,
modifyQueriesAction,
queriesImportedAction,
queryStoreSubscriptionAction,
queryStreamUpdatedAction,
richHistoryUpdatedAction,
scanStartAction,
scanStopAction,
setQueriesAction,
setUrlReplacedAction,
splitCloseAction,
splitOpenAction,
syncTimesAction,
updateDatasourceInstanceAction,
} from './actionTypes';
import { getTimeZone } from 'app/features/profile/state/selectors';
import { getShiftedTimeRange } from 'app/core/utils/timePicker';
import { notifyApp, updateLocation } from '../../../core/actions';
import { getTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv';
import { preProcessPanelData, runRequest } from '../../dashboard/state/runRequest';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { getExploreDatasources } from './selectors';
import { serializeStateToUrlParam } from '@grafana/data/src/utils/url';
import {
decorateWithGraphLogsTraceAndTable,
decorateWithGraphResult,
decorateWithLogsResult,
decorateWithTableResult,
} from '../utils/decorators';
import { createErrorNotification } from '../../../core/copy/appNotification';
/**
* Adds a query row after the row with the given index.
*/
export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult<void> {
return (dispatch, getState) => {
const queries = getState().explore[exploreId].queries;
const query = generateEmptyQuery(queries, index);
dispatch(addQueryRowAction({ exploreId, index, query }));
};
}
/**
* Loads a new datasource identified by the given name.
*/
export function changeDatasource(
exploreId: ExploreId,
datasourceName: string,
options?: { importQueries: boolean }
): ThunkResult<void> {
return async (dispatch, getState) => {
let newDataSourceInstance: DataSourceApi;
if (!datasourceName) {
newDataSourceInstance = await getDatasourceSrv().get();
} else {
newDataSourceInstance = await getDatasourceSrv().get(datasourceName);
}
const currentDataSourceInstance = getState().explore[exploreId].datasourceInstance;
const queries = getState().explore[exploreId].queries;
const orgId = getState().user.orgId;
dispatch(
updateDatasourceInstanceAction({
exploreId,
datasourceInstance: newDataSourceInstance,
})
);
if (options?.importQueries) {
await dispatch(importQueries(exploreId, queries, currentDataSourceInstance, newDataSourceInstance));
}
if (getState().explore[exploreId].isLive) {
dispatch(changeRefreshInterval(exploreId, RefreshPicker.offOption.value));
}
await dispatch(loadDatasource(exploreId, newDataSourceInstance, orgId));
// Exception - we only want to run queries on data source change, if the queries were imported
if (options?.importQueries) {
dispatch(runQueries(exploreId));
}
};
}
/**
* Query change handler for the query row with the given index.
* If `override` is reset the query modifications and run the queries. Use this to set queries via a link.
*/
export function changeQuery(
exploreId: ExploreId,
query: DataQuery,
index: number,
override = false
): ThunkResult<void> {
return (dispatch, getState) => {
// Null query means reset
if (query === null) {
const queries = getState().explore[exploreId].queries;
const { refId, key } = queries[index];
query = generateNewKeyAndAddRefIdIfMissing({ refId, key }, queries, index);
}
dispatch(changeQueryAction({ exploreId, query, index, override }));
if (override) {
dispatch(runQueries(exploreId));
}
};
}
/**
* Keep track of the Explore container size, in particular the width.
* The width will be used to calculate graph intervals (number of datapoints).
*/
export function changeSize(
exploreId: ExploreId,
{ height, width }: { height: number; width: number }
): PayloadAction<ChangeSizePayload> {
return changeSizeAction({ exploreId, height, width });
}
export const updateTimeRange = (options: {
exploreId: ExploreId;
rawRange?: RawTimeRange;
absoluteRange?: AbsoluteTimeRange;
}): ThunkResult<void> => {
return (dispatch, getState) => {
const { syncedTimes } = getState().explore;
if (syncedTimes) {
dispatch(updateTime({ ...options, exploreId: ExploreId.left }));
dispatch(runQueries(ExploreId.left));
dispatch(updateTime({ ...options, exploreId: ExploreId.right }));
dispatch(runQueries(ExploreId.right));
} else {
dispatch(updateTime({ ...options }));
dispatch(runQueries(options.exploreId));
}
};
};
/**
* Change the refresh interval of Explore. Called from the Refresh picker.
*/
export function changeRefreshInterval(
exploreId: ExploreId,
refreshInterval: string
): PayloadAction<ChangeRefreshIntervalPayload> {
return changeRefreshIntervalAction({ exploreId, refreshInterval });
}
/**
* Change logs deduplication strategy.
*/
export const changeDedupStrategy = (
exploreId: ExploreId,
dedupStrategy: LogsDedupStrategy
): PayloadAction<ChangeDedupStrategyPayload> => {
return changeDedupStrategyAction({ exploreId, dedupStrategy });
};
/**
* Clear all queries and results.
*/
export function clearQueries(exploreId: ExploreId): ThunkResult<void> {
return dispatch => {
dispatch(scanStopAction({ exploreId }));
dispatch(clearQueriesAction({ exploreId }));
dispatch(stateSave());
};
}
/**
* Cancel running queries
*/
export function cancelQueries(exploreId: ExploreId): ThunkResult<void> {
return dispatch => {
dispatch(scanStopAction({ exploreId }));
dispatch(cancelQueriesAction({ exploreId }));
dispatch(stateSave());
};
}
/**
* Loads all explore data sources and sets the chosen datasource.
* If there are no datasources a missing datasource action is dispatched.
*/
export function loadExploreDatasourcesAndSetDatasource(
exploreId: ExploreId,
datasourceName: string
): ThunkResult<void> {
return async dispatch => {
const exploreDatasources = getExploreDatasources();
if (exploreDatasources.length >= 1) {
await dispatch(changeDatasource(exploreId, datasourceName, { importQueries: true }));
} else {
dispatch(loadDatasourceMissingAction({ exploreId }));
}
};
}
/**
* Initialize Explore state with state from the URL and the React component.
* Call this only on components for with the Explore state has not been initialized.
*/
export function initializeExplore(
exploreId: ExploreId,
datasourceName: string,
queries: DataQuery[],
range: TimeRange,
containerWidth: number,
eventBridge: EventBusExtended,
originPanelId?: number | null
): ThunkResult<void> {
return async (dispatch, getState) => {
dispatch(loadExploreDatasourcesAndSetDatasource(exploreId, datasourceName));
dispatch(
initializeExploreAction({
exploreId,
containerWidth,
eventBridge,
queries,
range,
originPanelId,
})
);
dispatch(updateTime({ exploreId }));
const richHistory = getRichHistory();
dispatch(richHistoryUpdatedAction({ richHistory }));
};
}
/**
* Datasource loading was successfully completed.
*/
export const loadDatasourceReady = (
exploreId: ExploreId,
instance: DataSourceApi,
orgId: number
): PayloadAction<LoadDatasourceReadyPayload> => {
const historyKey = `grafana.explore.history.${instance.meta?.id}`;
const history = store.getObject(historyKey, []);
// Save last-used datasource
store.set(lastUsedDatasourceKeyForOrgId(orgId), instance.name);
return loadDatasourceReadyAction({
exploreId,
history,
});
};
/**
* Import queries from previous datasource if possible eg Loki and Prometheus have similar query language so the
* labels part can be reused to get similar data.
* @param exploreId
* @param queries
* @param sourceDataSource
* @param targetDataSource
*/
export const importQueries = (
exploreId: ExploreId,
queries: DataQuery[],
sourceDataSource: DataSourceApi | undefined | null,
targetDataSource: DataSourceApi
): ThunkResult<void> => {
return async dispatch => {
if (!sourceDataSource) {
// explore not initialized
dispatch(queriesImportedAction({ exploreId, queries }));
return;
}
let importedQueries = queries;
// Check if queries can be imported from previously selected datasource
if (sourceDataSource.meta?.id === targetDataSource.meta?.id) {
// Keep same queries if same type of datasource
importedQueries = [...queries];
} else if (targetDataSource.importQueries) {
// Datasource-specific importers
importedQueries = await targetDataSource.importQueries(queries, sourceDataSource.meta);
} else {
// Default is blank queries
importedQueries = ensureQueries();
}
const nextQueries = ensureQueries(importedQueries);
dispatch(queriesImportedAction({ exploreId, queries: nextQueries }));
};
};
/**
* Main action to asynchronously load a datasource. Dispatches lots of smaller actions for feedback.
*/
export const loadDatasource = (exploreId: ExploreId, instance: DataSourceApi, orgId: number): ThunkResult<void> => {
return async (dispatch, getState) => {
const datasourceName = instance.name;
// Keep ID to track selection
dispatch(loadDatasourcePendingAction({ exploreId, requestedDatasourceName: datasourceName }));
if (instance.init) {
try {
instance.init();
} catch (err) {
console.error(err);
}
}
if (datasourceName !== getState().explore[exploreId].requestedDatasourceName) {
// User already changed datasource, discard results
return;
}
dispatch(loadDatasourceReady(exploreId, instance, orgId));
};
};
/**
* Action to modify a query given a datasource-specific modifier action.
* @param exploreId Explore area
* @param modification Action object with a type, e.g., ADD_FILTER
* @param index Optional query row index. If omitted, the modification is applied to all query rows.
* @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`.
*/
export function modifyQueries(
exploreId: ExploreId,
modification: QueryFixAction,
modifier: any,
index?: number
): ThunkResult<void> {
return dispatch => {
dispatch(modifyQueriesAction({ exploreId, modification, index, modifier }));
if (!modification.preventSubmit) {
dispatch(runQueries(exploreId));
}
};
}
/**
* Main action to run queries and dispatches sub-actions based on which result viewers are active
*/
export const runQueries = (exploreId: ExploreId): ThunkResult<void> => {
return (dispatch, getState) => {
dispatch(updateTime({ exploreId }));
const richHistory = getState().explore.richHistory;
const exploreItemState = getState().explore[exploreId];
const {
datasourceInstance,
queries,
containerWidth,
isLive: live,
range,
scanning,
queryResponse,
querySubscription,
history,
refreshInterval,
absoluteRange,
} = exploreItemState;
if (!hasNonEmptyQuery(queries)) {
dispatch(clearQueriesAction({ exploreId }));
dispatch(stateSave()); // Remember to save to state and update location
return;
}
if (!datasourceInstance) {
return;
}
// Some datasource's query builders allow per-query interval limits,
// but we're using the datasource interval limit for now
const minInterval = datasourceInstance?.interval;
stopQueryState(querySubscription);
const datasourceId = datasourceInstance?.meta.id;
const queryOptions: QueryOptions = {
minInterval,
// maxDataPoints is used in:
// Loki - used for logs streaming for buffer size, with undefined it falls back to datasource config if it supports that.
// Elastic - limits the number of datapoints for the counts query and for logs it has hardcoded limit.
// Influx - used to correctly display logs in graph
// TODO:unification
// maxDataPoints: mode === ExploreMode.Logs && datasourceId === 'loki' ? undefined : containerWidth,
maxDataPoints: containerWidth,
liveStreaming: live,
};
const datasourceName = exploreItemState.requestedDatasourceName;
const timeZone = getTimeZone(getState().user);
const transaction = buildQueryTransaction(queries, queryOptions, range, scanning, timeZone);
let firstResponse = true;
dispatch(changeLoadingStateAction({ exploreId, loadingState: LoadingState.Loading }));
const newQuerySub = runRequest(datasourceInstance, transaction.request)
.pipe(
// Simple throttle for live tailing, in case of > 1000 rows per interval we spend about 200ms on processing and
// rendering. In case this is optimized this can be tweaked, but also it should be only as fast as user
// actually can see what is happening.
live ? throttleTime(500) : identity,
map((data: PanelData) => preProcessPanelData(data, queryResponse)),
map(decorateWithGraphLogsTraceAndTable),
map(decorateWithGraphResult),
map(decorateWithLogsResult({ absoluteRange, refreshInterval })),
mergeMap(decorateWithTableResult)
)
.subscribe(
data => {
if (!data.error && firstResponse) {
// Side-effect: Saving history in localstorage
const nextHistory = updateHistory(history, datasourceId, queries);
const nextRichHistory = addToRichHistory(
richHistory || [],
datasourceId,
datasourceName,
queries,
false,
'',
''
);
dispatch(historyUpdatedAction({ exploreId, history: nextHistory }));
dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory }));
// We save queries to the URL here so that only successfully run queries change the URL.
dispatch(stateSave());
}
firstResponse = false;
dispatch(queryStreamUpdatedAction({ exploreId, response: data }));
// Keep scanning for results if this was the last scanning transaction
if (getState().explore[exploreId].scanning) {
if (data.state === LoadingState.Done && data.series.length === 0) {
const range = getShiftedTimeRange(-1, getState().explore[exploreId].range);
dispatch(updateTime({ exploreId, absoluteRange: range }));
dispatch(runQueries(exploreId));
} else {
// We can stop scanning if we have a result
dispatch(scanStopAction({ exploreId }));
}
}
},
error => {
dispatch(notifyApp(createErrorNotification('Query processing error', error)));
dispatch(changeLoadingStateAction({ exploreId, loadingState: LoadingState.Error }));
console.error(error);
}
);
dispatch(queryStoreSubscriptionAction({ exploreId, querySubscription: newQuerySub }));
};
};
export const updateRichHistory = (ts: number, property: string, updatedProperty?: string): ThunkResult<void> => {
return (dispatch, getState) => {
// Side-effect: Saving rich history in localstorage
let nextRichHistory;
if (property === 'starred') {
nextRichHistory = updateStarredInRichHistory(getState().explore.richHistory, ts);
}
if (property === 'comment') {
nextRichHistory = updateCommentInRichHistory(getState().explore.richHistory, ts, updatedProperty);
}
if (property === 'delete') {
nextRichHistory = deleteQueryInRichHistory(getState().explore.richHistory, ts);
}
dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory }));
};
};
export const deleteRichHistory = (): ThunkResult<void> => {
return dispatch => {
deleteAllFromRichHistory();
dispatch(richHistoryUpdatedAction({ richHistory: [] }));
};
};
export const toRawTimeRange = (range: TimeRange): RawTimeRange => {
let from = range.raw.from;
if (isDateTime(from)) {
from = from.valueOf().toString(10);
}
let to = range.raw.to;
if (isDateTime(to)) {
to = to.valueOf().toString(10);
}
return {
from,
to,
};
};
/**
* Save local redux state back to the URL. Should be called when there is some change that should affect the URL.
* Not all of the redux state is reflected in URL though.
*/
export const stateSave = (): ThunkResult<void> => {
return (dispatch, getState) => {
const { left, right, split } = getState().explore;
const orgId = getState().user.orgId.toString();
const replace = left && left.urlReplaced === false;
const urlStates: { [index: string]: string } = { orgId };
const leftUrlState: ExploreUrlState = {
datasource: left.datasourceInstance!.name,
queries: left.queries.map(clearQueryKeys),
range: toRawTimeRange(left.range),
};
urlStates.left = serializeStateToUrlParam(leftUrlState, true);
if (split) {
const rightUrlState: ExploreUrlState = {
datasource: right.datasourceInstance!.name,
queries: right.queries.map(clearQueryKeys),
range: toRawTimeRange(right.range),
};
urlStates.right = serializeStateToUrlParam(rightUrlState, true);
}
dispatch(updateLocation({ query: urlStates, replace }));
if (replace) {
dispatch(setUrlReplacedAction({ exploreId: ExploreId.left }));
}
};
};
export const updateTime = (config: {
exploreId: ExploreId;
rawRange?: RawTimeRange;
absoluteRange?: AbsoluteTimeRange;
}): ThunkResult<void> => {
return (dispatch, getState) => {
const { exploreId, absoluteRange: absRange, rawRange: actionRange } = config;
const itemState = getState().explore[exploreId];
const timeZone = getTimeZone(getState().user);
const { range: rangeInState } = itemState;
let rawRange: RawTimeRange = rangeInState.raw;
if (absRange) {
rawRange = {
from: dateTimeForTimeZone(timeZone, absRange.from),
to: dateTimeForTimeZone(timeZone, absRange.to),
};
}
if (actionRange) {
rawRange = actionRange;
}
const range = getTimeRange(timeZone, rawRange);
const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
getTimeSrv().init(
new DashboardModel({
time: range.raw,
refresh: false,
timeZone,
})
);
dispatch(changeRangeAction({ exploreId, range, absoluteRange }));
};
};
/**
* Start a scan for more results using the given scanner.
* @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> {
return (dispatch, getState) => {
// Register the scanner
dispatch(scanStartAction({ exploreId }));
// Scanning must trigger query run, and return the new range
const range = getShiftedTimeRange(-1, getState().explore[exploreId].range);
// Set the new range to be displayed
dispatch(updateTime({ exploreId, absoluteRange: range }));
dispatch(runQueries(exploreId));
};
}
/**
* 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> {
return (dispatch, getState) => {
// Inject react keys into query objects
const queries = getState().explore[exploreId].queries;
const nextQueries = rawQueries.map((query, index) => generateNewKeyAndAddRefIdIfMissing(query, queries, index));
dispatch(setQueriesAction({ exploreId, queries: nextQueries }));
dispatch(runQueries(exploreId));
};
}
/**
* Close the split view and save URL state.
*/
export function splitClose(itemId: ExploreId): ThunkResult<void> {
return dispatch => {
dispatch(splitCloseAction({ itemId }));
dispatch(stateSave());
};
}
/**
* Open the split view and the right state is automatically initialized.
* If options are specified it initializes that pane with the datasource and query from options.
* Otherwise it copies the left state to be the right state. The copy keeps all query modifications but wipes the query
* results.
*/
export function splitOpen<T extends DataQuery = any>(options?: {
datasourceUid: string;
query: T;
// Don't use right now. It's used for Traces to Logs interaction but is hacky in how the range is actually handled.
range?: TimeRange;
}): ThunkResult<void> {
return async (dispatch, getState) => {
// Clone left state to become the right state
const leftState: ExploreItemState = getState().explore[ExploreId.left];
const rightState: ExploreItemState = {
...leftState,
};
const queryState = getState().location.query[ExploreId.left] as string;
const urlState = parseUrlState(queryState);
if (options) {
rightState.queries = [];
rightState.graphResult = null;
rightState.logsResult = null;
rightState.tableResult = null;
rightState.queryKeys = [];
urlState.queries = [];
rightState.urlState = urlState;
if (options.range) {
urlState.range = options.range.raw;
// This is super hacky. In traces to logs we want to create a link but also internally open split window.
// We use the same range object but the raw part is treated differently because it's parsed differently during
// init depending on whether we open split or new window.
rightState.range = {
...options.range,
raw: {
from: options.range.from.utc().toISOString(),
to: options.range.to.utc().toISOString(),
},
};
}
dispatch(splitOpenAction({ itemState: rightState }));
const queries = [
{
...options.query,
refId: 'A',
} as DataQuery,
];
const dataSourceSettings = getDatasourceSrv().getDataSourceSettingsByUid(options.datasourceUid);
await dispatch(changeDatasource(ExploreId.right, dataSourceSettings!.name));
await dispatch(setQueriesAction({ exploreId: ExploreId.right, queries }));
await dispatch(runQueries(ExploreId.right));
} else {
rightState.queries = leftState.queries.slice();
rightState.urlState = urlState;
dispatch(splitOpenAction({ itemState: rightState }));
}
dispatch(stateSave());
};
}
/**
* 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> {
return (dispatch, getState) => {
if (exploreId === ExploreId.left) {
const leftState = getState().explore.left;
dispatch(updateTimeRange({ exploreId: ExploreId.right, rawRange: leftState.range.raw }));
} else {
const rightState = getState().explore.right;
dispatch(updateTimeRange({ exploreId: ExploreId.left, rawRange: rightState.range.raw }));
}
const isTimeSynced = getState().explore.syncedTimes;
dispatch(syncTimesAction({ syncedTimes: !isTimeSynced }));
dispatch(stateSave());
};
}
/**
* Reacts to changes in URL state that we need to sync back to our redux state. Checks the internal update variable
* to see which parts change and need to be synced.
* @param exploreId
*/
export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
return (dispatch, getState) => {
const itemState = getState().explore[exploreId];
if (!itemState.initialized) {
return;
}
const { urlState, update, containerWidth, eventBridge } = itemState;
if (!urlState) {
return;
}
const { datasource, queries, range: urlRange, originPanelId } = urlState;
const refreshQueries: DataQuery[] = [];
for (let index = 0; index < queries.length; index++) {
const query = queries[index];
refreshQueries.push(generateNewKeyAndAddRefIdIfMissing(query, refreshQueries, index));
}
const timeZone = getTimeZone(getState().user);
const range = getTimeRangeFromUrl(urlRange, timeZone);
// need to refresh datasource
if (update.datasource) {
const initialQueries = ensureQueries(queries);
dispatch(
initializeExplore(exploreId, datasource, initialQueries, range, containerWidth, eventBridge, originPanelId)
);
return;
}
if (update.range) {
dispatch(updateTime({ exploreId, rawRange: range.raw }));
}
// need to refresh queries
if (update.queries) {
dispatch(setQueriesAction({ exploreId, queries: refreshQueries }));
}
// always run queries when refresh is needed
if (update.queries || update.range) {
dispatch(runQueries(exploreId));
}
};
}
export interface NavigateToExploreDependencies {
getDataSourceSrv: () => DataSourceSrv;
getTimeSrv: () => TimeSrv;
getExploreUrl: (args: GetExploreUrlArguments) => Promise<string | undefined>;
openInNewWindow?: (url: string) => void;
}
export const navigateToExplore = (
panel: PanelModel,
dependencies: NavigateToExploreDependencies
): ThunkResult<void> => {
return async dispatch => {
const { getDataSourceSrv, getTimeSrv, getExploreUrl, openInNewWindow } = dependencies;
const datasourceSrv = getDataSourceSrv();
const datasource = await datasourceSrv.get(panel.datasource);
const path = await getExploreUrl({
panel,
panelTargets: panel.targets,
panelDatasource: datasource,
datasourceSrv,
timeSrv: getTimeSrv(),
});
if (openInNewWindow && path) {
openInNewWindow(path);
return;
}
const query = {}; // strips any angular query param
dispatch(updateLocation({ path, query }));
};
};

View File

@@ -0,0 +1,115 @@
import {
loadDatasource,
loadDatasourcePendingAction,
loadDatasourceReadyAction,
updateDatasourceInstanceAction,
datasourceReducer,
} from './datasource';
import { ExploreId, ExploreItemState } from 'app/types';
import { thunkTester } from 'test/core/thunk/thunkTester';
import { DataQuery, DataSourceApi } from '@grafana/data';
import { createEmptyQueryResponse } from './utils';
import { reducerTester } from '../../../../test/core/redux/reducerTester';
describe('loading datasource', () => {
describe('when loadDatasource thunk is dispatched', () => {
describe('and all goes fine', () => {
it('then it should dispatch correct actions', async () => {
const exploreId = ExploreId.left;
const name = 'some-datasource';
const initialState = { explore: { [exploreId]: { requestedDatasourceName: name } } };
const mockDatasourceInstance = {
testDatasource: () => {
return Promise.resolve({ status: 'success' });
},
name,
init: jest.fn(),
meta: { id: 'some id' },
};
const dispatchedActions = await thunkTester(initialState)
.givenThunk(loadDatasource)
.whenThunkIsDispatched(exploreId, mockDatasourceInstance);
expect(dispatchedActions).toEqual([
loadDatasourcePendingAction({
exploreId,
requestedDatasourceName: mockDatasourceInstance.name,
}),
loadDatasourceReadyAction({ exploreId, history: [] }),
]);
});
});
describe('and user changes datasource during load', () => {
it('then it should dispatch correct actions', async () => {
const exploreId = ExploreId.left;
const name = 'some-datasource';
const initialState = { explore: { [exploreId]: { requestedDatasourceName: 'some-other-datasource' } } };
const mockDatasourceInstance = {
testDatasource: () => {
return Promise.resolve({ status: 'success' });
},
name,
init: jest.fn(),
meta: { id: 'some id' },
};
const dispatchedActions = await thunkTester(initialState)
.givenThunk(loadDatasource)
.whenThunkIsDispatched(exploreId, mockDatasourceInstance);
expect(dispatchedActions).toEqual([
loadDatasourcePendingAction({
exploreId,
requestedDatasourceName: mockDatasourceInstance.name,
}),
]);
});
});
});
});
describe('Explore item reducer', () => {
describe('changing datasource', () => {
describe('when updateDatasourceInstanceAction is dispatched', () => {
describe('and datasourceInstance supports graph, logs, table and has a startpage', () => {
it('then it should set correct state', () => {
const StartPage = {};
const datasourceInstance = {
meta: {
metrics: true,
logs: true,
},
components: {
ExploreStartPage: StartPage,
},
} as DataSourceApi;
const queries: DataQuery[] = [];
const queryKeys: string[] = [];
const initialState: ExploreItemState = ({
datasourceInstance: null,
queries,
queryKeys,
} as unknown) as ExploreItemState;
const expectedState: any = {
datasourceInstance,
queries,
queryKeys,
graphResult: null,
logsResult: null,
tableResult: null,
latency: 0,
loading: false,
queryResponse: createEmptyQueryResponse(),
};
reducerTester<ExploreItemState>()
.givenReducer(datasourceReducer, initialState)
.whenActionIsDispatched(updateDatasourceInstanceAction({ exploreId: ExploreId.left, datasourceInstance }))
.thenStateShouldEqual(expectedState);
});
});
});
});
});

View File

@@ -0,0 +1,235 @@
// Libraries
import { AnyAction, createAction, PayloadAction } from '@reduxjs/toolkit';
import { RefreshPicker } from '@grafana/ui';
import { DataSourceApi, HistoryItem } from '@grafana/data';
import store from 'app/core/store';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { lastUsedDatasourceKeyForOrgId, stopQueryState } from 'app/core/utils/explore';
import { ExploreItemState, ThunkResult } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { getExploreDatasources } from './selectors';
import { importQueries, runQueries } from './query';
import { changeRefreshInterval } from './time';
import { createEmptyQueryResponse, makeInitialUpdateState } from './utils';
//
// Actions and Payloads
//
/**
* Display an error when no datasources have been configured
*/
export interface LoadDatasourceMissingPayload {
exploreId: ExploreId;
}
export const loadDatasourceMissingAction = createAction<LoadDatasourceMissingPayload>('explore/loadDatasourceMissing');
/**
* Start the async process of loading a datasource to display a loading indicator
*/
export interface LoadDatasourcePendingPayload {
exploreId: ExploreId;
requestedDatasourceName: string;
}
export const loadDatasourcePendingAction = createAction<LoadDatasourcePendingPayload>('explore/loadDatasourcePending');
/**
* Datasource loading was completed.
*/
export interface LoadDatasourceReadyPayload {
exploreId: ExploreId;
history: HistoryItem[];
}
export const loadDatasourceReadyAction = createAction<LoadDatasourceReadyPayload>('explore/loadDatasourceReady');
/**
* Updates datasource instance before datasource loading has started
*/
export interface UpdateDatasourceInstancePayload {
exploreId: ExploreId;
datasourceInstance: DataSourceApi;
}
export const updateDatasourceInstanceAction = createAction<UpdateDatasourceInstancePayload>(
'explore/updateDatasourceInstance'
);
//
// Action creators
//
/**
* Loads a new datasource identified by the given name.
*/
export function changeDatasource(
exploreId: ExploreId,
datasourceName: string,
options?: { importQueries: boolean }
): ThunkResult<void> {
return async (dispatch, getState) => {
let newDataSourceInstance: DataSourceApi;
if (!datasourceName) {
newDataSourceInstance = await getDatasourceSrv().get();
} else {
newDataSourceInstance = await getDatasourceSrv().get(datasourceName);
}
const currentDataSourceInstance = getState().explore[exploreId].datasourceInstance;
const queries = getState().explore[exploreId].queries;
const orgId = getState().user.orgId;
dispatch(
updateDatasourceInstanceAction({
exploreId,
datasourceInstance: newDataSourceInstance,
})
);
if (options?.importQueries) {
await dispatch(importQueries(exploreId, queries, currentDataSourceInstance, newDataSourceInstance));
}
if (getState().explore[exploreId].isLive) {
dispatch(changeRefreshInterval(exploreId, RefreshPicker.offOption.value));
}
await dispatch(loadDatasource(exploreId, newDataSourceInstance, orgId));
// Exception - we only want to run queries on data source change, if the queries were imported
if (options?.importQueries) {
dispatch(runQueries(exploreId));
}
};
}
/**
* Loads all explore data sources and sets the chosen datasource.
* If there are no datasources a missing datasource action is dispatched.
*/
export function loadExploreDatasourcesAndSetDatasource(
exploreId: ExploreId,
datasourceName: string
): ThunkResult<void> {
return async dispatch => {
const exploreDatasources = getExploreDatasources();
if (exploreDatasources.length >= 1) {
await dispatch(changeDatasource(exploreId, datasourceName, { importQueries: true }));
} else {
dispatch(loadDatasourceMissingAction({ exploreId }));
}
};
}
/**
* Datasource loading was successfully completed.
*/
export const loadDatasourceReady = (
exploreId: ExploreId,
instance: DataSourceApi,
orgId: number
): PayloadAction<LoadDatasourceReadyPayload> => {
const historyKey = `grafana.explore.history.${instance.meta?.id}`;
const history = store.getObject(historyKey, []);
// Save last-used datasource
store.set(lastUsedDatasourceKeyForOrgId(orgId), instance.name);
return loadDatasourceReadyAction({
exploreId,
history,
});
};
/**
* Main action to asynchronously load a datasource. Dispatches lots of smaller actions for feedback.
*/
export const loadDatasource = (exploreId: ExploreId, instance: DataSourceApi, orgId: number): ThunkResult<void> => {
return async (dispatch, getState) => {
const datasourceName = instance.name;
// Keep ID to track selection
dispatch(loadDatasourcePendingAction({ exploreId, requestedDatasourceName: datasourceName }));
if (instance.init) {
try {
instance.init();
} catch (err) {
console.error(err);
}
}
if (datasourceName !== getState().explore[exploreId].requestedDatasourceName) {
// User already changed datasource, discard results
return;
}
dispatch(loadDatasourceReady(exploreId, instance, orgId));
};
};
//
// Reducer
//
/**
* Reducer for an Explore area, to be used by the global Explore reducer.
*/
// Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated.
// ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice
// because the state would become frozen and during run time we would get errors because flot (Graph lib) would try to mutate
// the frozen state.
// https://github.com/reduxjs/redux-toolkit/issues/242
export const datasourceReducer = (state: ExploreItemState, action: AnyAction): ExploreItemState => {
if (updateDatasourceInstanceAction.match(action)) {
const { datasourceInstance } = action.payload;
// Custom components
stopQueryState(state.querySubscription);
return {
...state,
datasourceInstance,
graphResult: null,
tableResult: null,
logsResult: null,
latency: 0,
queryResponse: createEmptyQueryResponse(),
loading: false,
queryKeys: [],
originPanelId: state.urlState && state.urlState.originPanelId,
};
}
if (loadDatasourceMissingAction.match(action)) {
return {
...state,
datasourceMissing: true,
datasourceLoading: false,
update: makeInitialUpdateState(),
};
}
if (loadDatasourcePendingAction.match(action)) {
return {
...state,
datasourceLoading: true,
requestedDatasourceName: action.payload.requestedDatasourceName,
};
}
if (loadDatasourceReadyAction.match(action)) {
const { history } = action.payload;
return {
...state,
history,
datasourceLoading: false,
datasourceMissing: false,
logsHighlighterExpressions: undefined,
update: makeInitialUpdateState(),
};
}
return state;
};

View File

@@ -0,0 +1,165 @@
import { PayloadAction } from '@reduxjs/toolkit';
import { DataQuery, DefaultTimeZone, ExploreUrlState, LogsDedupStrategy, toUtc, EventBusExtended } from '@grafana/data';
import { ExploreId, ExploreItemState, ExploreUpdateState } from 'app/types';
import { thunkTester } from 'test/core/thunk/thunkTester';
import {
changeDedupStrategyAction,
initializeExploreAction,
InitializeExplorePayload,
paneReducer,
refreshExplore,
} from './explorePane';
import { setQueriesAction } from './query';
import * as DatasourceSrv from 'app/features/plugins/datasource_srv';
import { makeExplorePaneState, makeInitialUpdateState } from './utils';
import { reducerTester } from '../../../../test/core/redux/reducerTester';
jest.mock('app/features/plugins/datasource_srv');
const getDatasourceSrvMock = (DatasourceSrv.getDatasourceSrv as any) as jest.Mock<DatasourceSrv.DatasourceSrv>;
beforeEach(() => {
getDatasourceSrvMock.mockClear();
getDatasourceSrvMock.mockImplementation(
() =>
({
getExternal: jest.fn().mockReturnValue([]),
get: jest.fn().mockReturnValue({
testDatasource: jest.fn(),
init: jest.fn(),
}),
} as any)
);
});
jest.mock('../../dashboard/services/TimeSrv', () => ({
getTimeSrv: jest.fn().mockReturnValue({
init: jest.fn(),
}),
}));
const t = toUtc();
const testRange = {
from: t,
to: t,
raw: {
from: t,
to: t,
},
};
const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
const exploreId = ExploreId.left;
const containerWidth = 1920;
const eventBridge = {} as EventBusExtended;
const timeZone = DefaultTimeZone;
const range = testRange;
const urlState: ExploreUrlState = {
datasource: 'some-datasource',
queries: [],
range: range.raw,
};
const updateDefaults = makeInitialUpdateState();
const update = { ...updateDefaults, ...updateOverides };
const initialState = {
user: {
orgId: '1',
timeZone,
},
explore: {
[exploreId]: {
initialized: true,
urlState,
containerWidth,
eventBridge,
update,
datasourceInstance: { name: 'some-datasource' },
queries: [] as DataQuery[],
range,
refreshInterval: {
label: 'Off',
value: 0,
},
},
},
};
return {
initialState,
exploreId,
range,
containerWidth,
eventBridge,
};
};
describe('refreshExplore', () => {
describe('when explore is initialized', () => {
describe('and update datasource is set', () => {
it('then it should dispatch initializeExplore', async () => {
const { exploreId, initialState, containerWidth, eventBridge } = setup({ datasource: true });
const dispatchedActions = await thunkTester(initialState)
.givenThunk(refreshExplore)
.whenThunkIsDispatched(exploreId);
const initializeExplore = dispatchedActions[1] as PayloadAction<InitializeExplorePayload>;
const { type, payload } = initializeExplore;
expect(type).toEqual(initializeExploreAction.type);
expect(payload.containerWidth).toEqual(containerWidth);
expect(payload.eventBridge).toEqual(eventBridge);
expect(payload.queries.length).toBe(1); // Queries have generated keys hard to expect on
expect(payload.range.from).toEqual(testRange.from);
expect(payload.range.to).toEqual(testRange.to);
expect(payload.range.raw.from).toEqual(testRange.raw.from);
expect(payload.range.raw.to).toEqual(testRange.raw.to);
});
});
describe('and update queries is set', () => {
it('then it should dispatch setQueriesAction', async () => {
const { exploreId, initialState } = setup({ queries: true });
const dispatchedActions = await thunkTester(initialState)
.givenThunk(refreshExplore)
.whenThunkIsDispatched(exploreId);
expect(dispatchedActions[0].type).toEqual(setQueriesAction.type);
expect(dispatchedActions[0].payload).toEqual({ exploreId, queries: [] });
});
});
});
describe('when update is not initialized', () => {
it('then it should not dispatch any actions', async () => {
const exploreId = ExploreId.left;
const initialState = { explore: { [exploreId]: { initialized: false } } };
const dispatchedActions = await thunkTester(initialState)
.givenThunk(refreshExplore)
.whenThunkIsDispatched(exploreId);
expect(dispatchedActions).toEqual([]);
});
});
});
describe('Explore item reducer', () => {
describe('changing dedup strategy', () => {
describe('when changeDedupStrategyAction is dispatched', () => {
it('then it should set correct dedup strategy in state', () => {
const initialState = makeExplorePaneState();
reducerTester<ExploreItemState>()
.givenReducer(paneReducer, initialState)
.whenActionIsDispatched(
changeDedupStrategyAction({ exploreId: ExploreId.left, dedupStrategy: LogsDedupStrategy.exact })
)
.thenStateShouldEqual({
...makeExplorePaneState(),
dedupStrategy: LogsDedupStrategy.exact,
});
});
});
});
});

View File

@@ -0,0 +1,292 @@
import { AnyAction } from 'redux';
import { getQueryKeys } from 'app/core/utils/explore';
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { queryReducer } from './query';
import { datasourceReducer } from './datasource';
import { timeReducer } from './time';
import { historyReducer } from './history';
import { makeExplorePaneState, makeInitialUpdateState } from './utils';
import { createAction, PayloadAction } from '@reduxjs/toolkit';
import { EventBusExtended, DataQuery, ExploreUrlState, LogLevel, LogsDedupStrategy, TimeRange } from '@grafana/data';
import {
clearQueryKeys,
ensureQueries,
generateNewKeyAndAddRefIdIfMissing,
getTimeRangeFromUrl,
} from 'app/core/utils/explore';
import { getRichHistory } from 'app/core/utils/richHistory';
// Types
import { ThunkResult } from 'app/types';
import { getTimeZone } from 'app/features/profile/state/selectors';
import { updateLocation } from '../../../core/actions';
import { serializeStateToUrlParam } from '@grafana/data/src/utils/url';
import { richHistoryUpdatedAction } from './main';
import { runQueries, setQueriesAction } from './query';
import { loadExploreDatasourcesAndSetDatasource } from './datasource';
import { updateTime } from './time';
import { toRawTimeRange } from '../utils/time';
//
// Actions and Payloads
//
/**
* Keep track of the Explore container size, in particular the width.
* The width will be used to calculate graph intervals (number of datapoints).
*/
export interface ChangeSizePayload {
exploreId: ExploreId;
width: number;
height: number;
}
export const changeSizeAction = createAction<ChangeSizePayload>('explore/changeSize');
/**
* Change deduplication strategy for logs.
*/
export interface ChangeDedupStrategyPayload {
exploreId: ExploreId;
dedupStrategy: LogsDedupStrategy;
}
export const changeDedupStrategyAction = createAction<ChangeDedupStrategyPayload>('explore/changeDedupStrategyAction');
/**
* Highlight expressions in the log results
*/
export interface HighlightLogsExpressionPayload {
exploreId: ExploreId;
expressions: string[];
}
export const highlightLogsExpressionAction = createAction<HighlightLogsExpressionPayload>(
'explore/highlightLogsExpression'
);
/**
* Initialize Explore state with state from the URL and the React component.
* Call this only on components for with the Explore state has not been initialized.
*/
export interface InitializeExplorePayload {
exploreId: ExploreId;
containerWidth: number;
eventBridge: EventBusExtended;
queries: DataQuery[];
range: TimeRange;
originPanelId?: number | null;
}
export const initializeExploreAction = createAction<InitializeExplorePayload>('explore/initializeExplore');
export interface ToggleLogLevelPayload {
exploreId: ExploreId;
hiddenLogLevels: LogLevel[];
}
export const toggleLogLevelAction = createAction<ToggleLogLevelPayload>('explore/toggleLogLevel');
export interface SetUrlReplacedPayload {
exploreId: ExploreId;
}
export const setUrlReplacedAction = createAction<SetUrlReplacedPayload>('explore/setUrlReplaced');
/**
* Keep track of the Explore container size, in particular the width.
* The width will be used to calculate graph intervals (number of datapoints).
*/
export function changeSize(
exploreId: ExploreId,
{ height, width }: { height: number; width: number }
): PayloadAction<ChangeSizePayload> {
return changeSizeAction({ exploreId, height, width });
}
/**
* Change logs deduplication strategy.
*/
export const changeDedupStrategy = (
exploreId: ExploreId,
dedupStrategy: LogsDedupStrategy
): PayloadAction<ChangeDedupStrategyPayload> => {
return changeDedupStrategyAction({ exploreId, dedupStrategy });
};
/**
* Initialize Explore state with state from the URL and the React component.
* Call this only on components for with the Explore state has not been initialized.
*/
export function initializeExplore(
exploreId: ExploreId,
datasourceName: string,
queries: DataQuery[],
range: TimeRange,
containerWidth: number,
eventBridge: EventBusExtended,
originPanelId?: number | null
): ThunkResult<void> {
return async (dispatch, getState) => {
dispatch(loadExploreDatasourcesAndSetDatasource(exploreId, datasourceName));
dispatch(
initializeExploreAction({
exploreId,
containerWidth,
eventBridge,
queries,
range,
originPanelId,
})
);
dispatch(updateTime({ exploreId }));
const richHistory = getRichHistory();
dispatch(richHistoryUpdatedAction({ richHistory }));
};
}
/**
* Save local redux state back to the URL. Should be called when there is some change that should affect the URL.
* Not all of the redux state is reflected in URL though.
*/
export const stateSave = (): ThunkResult<void> => {
return (dispatch, getState) => {
const { left, right, split } = getState().explore;
const orgId = getState().user.orgId.toString();
const replace = left && left.urlReplaced === false;
const urlStates: { [index: string]: string } = { orgId };
const leftUrlState: ExploreUrlState = {
datasource: left.datasourceInstance!.name,
queries: left.queries.map(clearQueryKeys),
range: toRawTimeRange(left.range),
};
urlStates.left = serializeStateToUrlParam(leftUrlState, true);
if (split) {
const rightUrlState: ExploreUrlState = {
datasource: right.datasourceInstance!.name,
queries: right.queries.map(clearQueryKeys),
range: toRawTimeRange(right.range),
};
urlStates.right = serializeStateToUrlParam(rightUrlState, true);
}
dispatch(updateLocation({ query: urlStates, replace }));
if (replace) {
dispatch(setUrlReplacedAction({ exploreId: ExploreId.left }));
}
};
};
/**
* Reacts to changes in URL state that we need to sync back to our redux state. Checks the internal update variable
* to see which parts change and need to be synced.
* @param exploreId
*/
export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
return (dispatch, getState) => {
const itemState = getState().explore[exploreId];
if (!itemState.initialized) {
return;
}
const { urlState, update, containerWidth, eventBridge } = itemState;
if (!urlState) {
return;
}
const { datasource, queries, range: urlRange, originPanelId } = urlState;
const refreshQueries: DataQuery[] = [];
for (let index = 0; index < queries.length; index++) {
const query = queries[index];
refreshQueries.push(generateNewKeyAndAddRefIdIfMissing(query, refreshQueries, index));
}
const timeZone = getTimeZone(getState().user);
const range = getTimeRangeFromUrl(urlRange, timeZone);
// need to refresh datasource
if (update.datasource) {
const initialQueries = ensureQueries(queries);
dispatch(
initializeExplore(exploreId, datasource, initialQueries, range, containerWidth, eventBridge, originPanelId)
);
return;
}
if (update.range) {
dispatch(updateTime({ exploreId, rawRange: range.raw }));
}
// need to refresh queries
if (update.queries) {
dispatch(setQueriesAction({ exploreId, queries: refreshQueries }));
}
// always run queries when refresh is needed
if (update.queries || update.range) {
dispatch(runQueries(exploreId));
}
};
}
/**
* Reducer for an Explore area, to be used by the global Explore reducer.
*/
// Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated.
// ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice
// because the state would become frozen and during run time we would get errors because flot (Graph lib) would try to mutate
// the frozen state.
// https://github.com/reduxjs/redux-toolkit/issues/242
export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), action: AnyAction): ExploreItemState => {
state = queryReducer(state, action);
state = datasourceReducer(state, action);
state = timeReducer(state, action);
state = historyReducer(state, action);
if (changeSizeAction.match(action)) {
const containerWidth = action.payload.width;
return { ...state, containerWidth };
}
if (highlightLogsExpressionAction.match(action)) {
const { expressions } = action.payload;
return { ...state, logsHighlighterExpressions: expressions };
}
if (changeDedupStrategyAction.match(action)) {
const { dedupStrategy } = action.payload;
return {
...state,
dedupStrategy,
};
}
if (initializeExploreAction.match(action)) {
const { containerWidth, eventBridge, queries, range, originPanelId } = action.payload;
return {
...state,
containerWidth,
eventBridge,
range,
queries,
initialized: true,
queryKeys: getQueryKeys(queries, state.datasourceInstance),
originPanelId,
update: makeInitialUpdateState(),
};
}
if (toggleLogLevelAction.match(action)) {
const { hiddenLogLevels } = action.payload;
return {
...state,
hiddenLogLevels: Array.from(hiddenLogLevels),
};
}
if (setUrlReplacedAction.match(action)) {
return {
...state,
urlReplaced: true,
};
}
return state;
};

View File

@@ -0,0 +1,58 @@
import {
deleteAllFromRichHistory,
deleteQueryInRichHistory,
updateCommentInRichHistory,
updateStarredInRichHistory,
} from 'app/core/utils/richHistory';
import { ExploreId, ExploreItemState, ThunkResult } from 'app/types';
import { richHistoryUpdatedAction } from './main';
import { HistoryItem } from '@grafana/data';
import { AnyAction, createAction } from '@reduxjs/toolkit';
//
// Actions and Payloads
//
export interface HistoryUpdatedPayload {
exploreId: ExploreId;
history: HistoryItem[];
}
export const historyUpdatedAction = createAction<HistoryUpdatedPayload>('explore/historyUpdated');
//
// Action creators
//
export const updateRichHistory = (ts: number, property: string, updatedProperty?: string): ThunkResult<void> => {
return (dispatch, getState) => {
// Side-effect: Saving rich history in localstorage
let nextRichHistory;
if (property === 'starred') {
nextRichHistory = updateStarredInRichHistory(getState().explore.richHistory, ts);
}
if (property === 'comment') {
nextRichHistory = updateCommentInRichHistory(getState().explore.richHistory, ts, updatedProperty);
}
if (property === 'delete') {
nextRichHistory = deleteQueryInRichHistory(getState().explore.richHistory, ts);
}
dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory }));
};
};
export const deleteRichHistory = (): ThunkResult<void> => {
return dispatch => {
deleteAllFromRichHistory();
dispatch(richHistoryUpdatedAction({ richHistory: [] }));
};
};
export const historyReducer = (state: ExploreItemState, action: AnyAction): ExploreItemState => {
if (historyUpdatedAction.match(action)) {
return {
...state,
history: action.payload.history,
};
}
return state;
};

View File

@@ -1,298 +1,140 @@
import {
DataQuery,
DataSourceApi,
dateTime,
LoadingState,
RawTimeRange,
UrlQueryMap,
ExploreUrlState,
LogsDedupStrategy,
} from '@grafana/data';
import {
createEmptyQueryResponse,
exploreReducer,
initialExploreState,
itemReducer,
makeExploreItemState,
makeInitialUpdateState,
} from './reducers';
import { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore';
import { reducerTester } from 'test/core/redux/reducerTester';
import {
changeRangeAction,
changeRefreshIntervalAction,
scanStartAction,
scanStopAction,
splitCloseAction,
splitOpenAction,
updateDatasourceInstanceAction,
addQueryRowAction,
removeQueryRowAction,
changeDedupStrategyAction,
} from './actionTypes';
import { updateLocation } from '../../../core/actions';
import { serializeStateToUrlParam } from '@grafana/data/src/utils/url';
import { exploreReducer, initialExploreState, navigateToExplore, splitCloseAction, splitOpenAction } from './main';
import { thunkTester } from 'test/core/thunk/thunkTester';
import { PanelModel } from 'app/features/dashboard/state';
import { updateLocation } from '../../../core/actions';
import { MockDataSourceApi } from '../../../../test/mocks/datasource_srv';
import { ExploreId, ExploreItemState, ExploreState } from '../../../types';
import { makeExplorePaneState, makeInitialUpdateState } from './utils';
import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { ExploreUrlState, UrlQueryMap } from '@grafana/data';
const QUERY_KEY_REGEX = /Q-(?:[a-z0-9]+-){5}(?:[0-9]+)/;
const getNavigateToExploreContext = async (openInNewWindow?: (url: string) => void) => {
const url = 'http://www.someurl.com';
const panel: Partial<PanelModel> = {
datasource: 'mocked datasource',
targets: [{ refId: 'A' }],
};
const datasource = new MockDataSourceApi(panel.datasource!);
const get = jest.fn().mockResolvedValue(datasource);
const getDataSourceSrv = jest.fn().mockReturnValue({ get });
const getTimeSrv = jest.fn();
const getExploreUrl = jest.fn().mockResolvedValue(url);
describe('Explore item reducer', () => {
describe('scanning', () => {
it('should start scanning', () => {
const initialState = {
...makeExploreItemState(),
scanning: false,
};
const dispatchedActions = await thunkTester({})
.givenThunk(navigateToExplore)
.whenThunkIsDispatched(panel, { getDataSourceSrv, getTimeSrv, getExploreUrl, openInNewWindow });
reducerTester<ExploreItemState>()
.givenReducer(itemReducer, initialState)
.whenActionIsDispatched(scanStartAction({ exploreId: ExploreId.left }))
.thenStateShouldEqual({
...makeExploreItemState(),
scanning: true,
});
});
it('should stop scanning', () => {
const initialState = {
...makeExploreItemState(),
scanning: true,
scanRange: {} as RawTimeRange,
};
return {
url,
panel,
datasource,
get,
getDataSourceSrv,
getTimeSrv,
getExploreUrl,
dispatchedActions,
};
};
reducerTester<ExploreItemState>()
.givenReducer(itemReducer, initialState)
.whenActionIsDispatched(scanStopAction({ exploreId: ExploreId.left }))
.thenStateShouldEqual({
...makeExploreItemState(),
scanning: false,
scanRange: undefined,
});
});
});
describe('navigateToExplore', () => {
describe('when navigateToExplore thunk is dispatched', () => {
describe('and openInNewWindow is undefined', () => {
const openInNewWindow: (url: string) => void = (undefined as unknown) as (url: string) => void;
it('then it should dispatch correct actions', async () => {
const { dispatchedActions, url } = await getNavigateToExploreContext(openInNewWindow);
describe('changing datasource', () => {
describe('when updateDatasourceInstanceAction is dispatched', () => {
describe('and datasourceInstance supports graph, logs, table and has a startpage', () => {
it('then it should set correct state', () => {
const StartPage = {};
const datasourceInstance = {
meta: {
metrics: true,
logs: true,
},
components: {
ExploreStartPage: StartPage,
},
} as DataSourceApi;
const queries: DataQuery[] = [];
const queryKeys: string[] = [];
const initialState: ExploreItemState = ({
datasourceInstance: null,
queries,
queryKeys,
} as unknown) as ExploreItemState;
const expectedState: any = {
datasourceInstance,
queries,
queryKeys,
graphResult: null,
logsResult: null,
tableResult: null,
latency: 0,
loading: false,
queryResponse: createEmptyQueryResponse(),
};
expect(dispatchedActions).toEqual([updateLocation({ path: url, query: {} })]);
});
reducerTester<ExploreItemState>()
.givenReducer(itemReducer, initialState)
.whenActionIsDispatched(updateDatasourceInstanceAction({ exploreId: ExploreId.left, datasourceInstance }))
.thenStateShouldEqual(expectedState);
it('then getDataSourceSrv should have been once', async () => {
const { getDataSourceSrv } = await getNavigateToExploreContext(openInNewWindow);
expect(getDataSourceSrv).toHaveBeenCalledTimes(1);
});
it('then getDataSourceSrv.get should have been called with correct arguments', async () => {
const { get, panel } = await getNavigateToExploreContext(openInNewWindow);
expect(get).toHaveBeenCalledTimes(1);
expect(get).toHaveBeenCalledWith(panel.datasource);
});
it('then getTimeSrv should have been called once', async () => {
const { getTimeSrv } = await getNavigateToExploreContext(openInNewWindow);
expect(getTimeSrv).toHaveBeenCalledTimes(1);
});
it('then getExploreUrl should have been called with correct arguments', async () => {
const { getExploreUrl, panel, datasource, getDataSourceSrv, getTimeSrv } = await getNavigateToExploreContext(
openInNewWindow
);
expect(getExploreUrl).toHaveBeenCalledTimes(1);
expect(getExploreUrl).toHaveBeenCalledWith({
panel,
panelTargets: panel.targets,
panelDatasource: datasource,
datasourceSrv: getDataSourceSrv(),
timeSrv: getTimeSrv(),
});
});
});
});
describe('changing refresh intervals', () => {
it("should result in 'streaming' state, when live-tailing is active", () => {
const initialState = makeExploreItemState();
const expectedState = {
...makeExploreItemState(),
refreshInterval: 'LIVE',
isLive: true,
loading: true,
logsResult: {
hasUniqueLabels: false,
rows: [] as any[],
},
queryResponse: {
...makeExploreItemState().queryResponse,
state: LoadingState.Streaming,
},
};
reducerTester<ExploreItemState>()
.givenReducer(itemReducer, initialState)
.whenActionIsDispatched(changeRefreshIntervalAction({ exploreId: ExploreId.left, refreshInterval: 'LIVE' }))
.thenStateShouldEqual(expectedState);
});
describe('and openInNewWindow is defined', () => {
const openInNewWindow: (url: string) => void = jest.fn();
it('then it should dispatch no actions', async () => {
const { dispatchedActions } = await getNavigateToExploreContext(openInNewWindow);
it("should result in 'done' state, when live-tailing is stopped", () => {
const initialState = makeExploreItemState();
const expectedState = {
...makeExploreItemState(),
refreshInterval: '',
logsResult: {
hasUniqueLabels: false,
rows: [] as any[],
},
queryResponse: {
...makeExploreItemState().queryResponse,
state: LoadingState.Done,
},
};
reducerTester<ExploreItemState>()
.givenReducer(itemReducer, initialState)
.whenActionIsDispatched(changeRefreshIntervalAction({ exploreId: ExploreId.left, refreshInterval: '' }))
.thenStateShouldEqual(expectedState);
});
});
describe('changing range', () => {
describe('when changeRangeAction is dispatched', () => {
it('then it should set correct state', () => {
reducerTester<ExploreItemState>()
.givenReducer(itemReducer, ({
update: { ...makeInitialUpdateState(), range: true },
range: null,
absoluteRange: null,
} as unknown) as ExploreItemState)
.whenActionIsDispatched(
changeRangeAction({
exploreId: ExploreId.left,
absoluteRange: { from: 1546297200000, to: 1546383600000 },
range: { from: dateTime('2019-01-01'), to: dateTime('2019-01-02'), raw: { from: 'now-1d', to: 'now' } },
})
)
.thenStateShouldEqual(({
update: { ...makeInitialUpdateState(), range: false },
absoluteRange: { from: 1546297200000, to: 1546383600000 },
range: { from: dateTime('2019-01-01'), to: dateTime('2019-01-02'), raw: { from: 'now-1d', to: 'now' } },
} as unknown) as ExploreItemState);
expect(dispatchedActions).toEqual([]);
});
});
});
describe('changing dedup strategy', () => {
describe('when changeDedupStrategyAction is dispatched', () => {
it('then it should set correct dedup strategy in state', () => {
const initialState = makeExploreItemState();
it('then getDataSourceSrv should have been once', async () => {
const { getDataSourceSrv } = await getNavigateToExploreContext(openInNewWindow);
reducerTester<ExploreItemState>()
.givenReducer(itemReducer, initialState)
.whenActionIsDispatched(
changeDedupStrategyAction({ exploreId: ExploreId.left, dedupStrategy: LogsDedupStrategy.exact })
)
.thenStateShouldEqual({
...makeExploreItemState(),
dedupStrategy: LogsDedupStrategy.exact,
});
expect(getDataSourceSrv).toHaveBeenCalledTimes(1);
});
});
});
describe('query rows', () => {
it('adds a new query row', () => {
reducerTester<ExploreItemState>()
.givenReducer(itemReducer, ({
queries: [],
} as unknown) as ExploreItemState)
.whenActionIsDispatched(
addQueryRowAction({
exploreId: ExploreId.left,
query: { refId: 'A', key: 'mockKey' },
index: 0,
})
)
.thenStateShouldEqual(({
queries: [{ refId: 'A', key: 'mockKey' }],
queryKeys: ['mockKey-0'],
} as unknown) as ExploreItemState);
});
it('removes a query row', () => {
reducerTester<ExploreItemState>()
.givenReducer(itemReducer, ({
queries: [
{ refId: 'A', key: 'mockKey' },
{ refId: 'B', key: 'mockKey' },
],
queryKeys: ['mockKey-0', 'mockKey-1'],
} as unknown) as ExploreItemState)
.whenActionIsDispatched(
removeQueryRowAction({
exploreId: ExploreId.left,
index: 0,
})
)
.thenStatePredicateShouldEqual((resultingState: ExploreItemState) => {
expect(resultingState.queries.length).toBe(1);
expect(resultingState.queries[0].refId).toBe('A');
expect(resultingState.queries[0].key).toMatch(QUERY_KEY_REGEX);
expect(resultingState.queryKeys[0]).toMatch(QUERY_KEY_REGEX);
return true;
});
});
it('reassigns query refId after removing a query to keep queries in order', () => {
reducerTester<ExploreItemState>()
.givenReducer(itemReducer, ({
queries: [{ refId: 'A' }, { refId: 'B' }, { refId: 'C' }],
queryKeys: ['undefined-0', 'undefined-1', 'undefined-2'],
} as unknown) as ExploreItemState)
.whenActionIsDispatched(
removeQueryRowAction({
exploreId: ExploreId.left,
index: 0,
})
)
.thenStatePredicateShouldEqual((resultingState: ExploreItemState) => {
expect(resultingState.queries.length).toBe(2);
const queriesRefIds = resultingState.queries.map(query => query.refId);
const queriesKeys = resultingState.queries.map(query => query.key);
expect(queriesRefIds).toEqual(['A', 'B']);
queriesKeys.forEach(queryKey => {
expect(queryKey).toMatch(QUERY_KEY_REGEX);
});
resultingState.queryKeys.forEach(queryKey => {
expect(queryKey).toMatch(QUERY_KEY_REGEX);
});
return true;
it('then getDataSourceSrv.get should have been called with correct arguments', async () => {
const { get, panel } = await getNavigateToExploreContext(openInNewWindow);
expect(get).toHaveBeenCalledTimes(1);
expect(get).toHaveBeenCalledWith(panel.datasource);
});
it('then getTimeSrv should have been called once', async () => {
const { getTimeSrv } = await getNavigateToExploreContext(openInNewWindow);
expect(getTimeSrv).toHaveBeenCalledTimes(1);
});
it('then getExploreUrl should have been called with correct arguments', async () => {
const { getExploreUrl, panel, datasource, getDataSourceSrv, getTimeSrv } = await getNavigateToExploreContext(
openInNewWindow
);
expect(getExploreUrl).toHaveBeenCalledTimes(1);
expect(getExploreUrl).toHaveBeenCalledWith({
panel,
panelTargets: panel.targets,
panelDatasource: datasource,
datasourceSrv: getDataSourceSrv(),
timeSrv: getTimeSrv(),
});
});
it('then openInNewWindow should have been called with correct arguments', async () => {
const openInNewWindowFunc = jest.fn();
const { url } = await getNavigateToExploreContext(openInNewWindowFunc);
expect(openInNewWindowFunc).toHaveBeenCalledTimes(1);
expect(openInNewWindowFunc).toHaveBeenCalledWith(url);
});
});
});
});
export const setup = (urlStateOverrides?: any) => {
const update = makeInitialUpdateState();
const urlStateDefaults: ExploreUrlState = {
datasource: 'some-datasource',
queries: [],
range: {
from: '',
to: '',
},
};
const urlState: ExploreUrlState = { ...urlStateDefaults, ...urlStateOverrides };
const serializedUrlState = serializeStateToUrlParam(urlState);
const initialState = ({
split: false,
left: { urlState, update },
right: { urlState, update },
} as unknown) as ExploreState;
return {
initialState,
serializedUrlState,
};
};
describe('Explore reducer', () => {
describe('split view', () => {
it("should make right pane a duplicate of the given item's state on split open", () => {
@@ -303,7 +145,7 @@ describe('Explore reducer', () => {
const initialState = ({
split: null,
left: leftItemMock as ExploreItemState,
right: makeExploreItemState(),
right: makeExplorePaneState(),
} as unknown) as ExploreState;
reducerTester<ExploreState>()
@@ -591,3 +433,27 @@ describe('Explore reducer', () => {
});
});
});
export const setup = (urlStateOverrides?: any) => {
const update = makeInitialUpdateState();
const urlStateDefaults: ExploreUrlState = {
datasource: 'some-datasource',
queries: [],
range: {
from: '',
to: '',
},
};
const urlState: ExploreUrlState = { ...urlStateDefaults, ...urlStateOverrides };
const serializedUrlState = serializeStateToUrlParam(urlState);
const initialState = ({
split: false,
left: { urlState, update },
right: { urlState, update },
} as unknown) as ExploreState;
return {
initialState,
serializedUrlState,
};
};

View File

@@ -0,0 +1,310 @@
import _ from 'lodash';
import { AnyAction } from 'redux';
import { DataSourceSrv, LocationUpdate } from '@grafana/runtime';
import { stopQueryState, parseUrlState, DEFAULT_RANGE, GetExploreUrlArguments } from 'app/core/utils/explore';
import { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore';
import { updateLocation } from '../../../core/actions';
import { paneReducer, stateSave } from './explorePane';
import { createAction } from '@reduxjs/toolkit';
import { makeExplorePaneState } from './utils';
import { DataQuery, TimeRange } from '@grafana/data';
import { ThunkResult } from '../../../types';
import { getDatasourceSrv } from '../../plugins/datasource_srv';
import { changeDatasource } from './datasource';
import { runQueries, setQueriesAction } from './query';
import { TimeSrv } from '../../dashboard/services/TimeSrv';
import { PanelModel } from 'app/features/dashboard/state';
//
// Actions and Payloads
//
/**
* Close the split view and save URL state.
*/
export interface SplitCloseActionPayload {
itemId: ExploreId;
}
export const splitCloseAction = createAction<SplitCloseActionPayload>('explore/splitClose');
/**
* Open the split view and copy the left state to be the right state.
* The right state is automatically initialized.
* The copy keeps all query modifications but wipes the query results.
*/
export interface SplitOpenPayload {
itemState: ExploreItemState;
}
export const splitOpenAction = createAction<SplitOpenPayload>('explore/splitOpen');
export interface SyncTimesPayload {
syncedTimes: boolean;
}
export const syncTimesAction = createAction<SyncTimesPayload>('explore/syncTimes');
export const richHistoryUpdatedAction = createAction<any>('explore/richHistoryUpdated');
/**
* Resets state for explore.
*/
export interface ResetExplorePayload {
force?: boolean;
}
export const resetExploreAction = createAction<ResetExplorePayload>('explore/resetExplore');
//
// Action creators
//
/**
* Open the split view and the right state is automatically initialized.
* If options are specified it initializes that pane with the datasource and query from options.
* Otherwise it copies the left state to be the right state. The copy keeps all query modifications but wipes the query
* results.
*/
export function splitOpen<T extends DataQuery = any>(options?: {
datasourceUid: string;
query: T;
// Don't use right now. It's used for Traces to Logs interaction but is hacky in how the range is actually handled.
range?: TimeRange;
}): ThunkResult<void> {
return async (dispatch, getState) => {
// Clone left state to become the right state
const leftState: ExploreItemState = getState().explore[ExploreId.left];
const rightState: ExploreItemState = {
...leftState,
};
const queryState = getState().location.query[ExploreId.left] as string;
const urlState = parseUrlState(queryState);
if (options) {
rightState.queries = [];
rightState.graphResult = null;
rightState.logsResult = null;
rightState.tableResult = null;
rightState.queryKeys = [];
urlState.queries = [];
rightState.urlState = urlState;
if (options.range) {
urlState.range = options.range.raw;
// This is super hacky. In traces to logs we want to create a link but also internally open split window.
// We use the same range object but the raw part is treated differently because it's parsed differently during
// init depending on whether we open split or new window.
rightState.range = {
...options.range,
raw: {
from: options.range.from.utc().toISOString(),
to: options.range.to.utc().toISOString(),
},
};
}
dispatch(splitOpenAction({ itemState: rightState }));
const queries = [
{
...options.query,
refId: 'A',
} as DataQuery,
];
const dataSourceSettings = getDatasourceSrv().getDataSourceSettingsByUid(options.datasourceUid);
await dispatch(changeDatasource(ExploreId.right, dataSourceSettings!.name));
await dispatch(setQueriesAction({ exploreId: ExploreId.right, queries }));
await dispatch(runQueries(ExploreId.right));
} else {
rightState.queries = leftState.queries.slice();
rightState.urlState = urlState;
dispatch(splitOpenAction({ itemState: rightState }));
}
dispatch(stateSave());
};
}
/**
* Close the split view and save URL state.
*/
export function splitClose(itemId: ExploreId): ThunkResult<void> {
return dispatch => {
dispatch(splitCloseAction({ itemId }));
dispatch(stateSave());
};
}
export interface NavigateToExploreDependencies {
getDataSourceSrv: () => DataSourceSrv;
getTimeSrv: () => TimeSrv;
getExploreUrl: (args: GetExploreUrlArguments) => Promise<string | undefined>;
openInNewWindow?: (url: string) => void;
}
export const navigateToExplore = (
panel: PanelModel,
dependencies: NavigateToExploreDependencies
): ThunkResult<void> => {
return async dispatch => {
const { getDataSourceSrv, getTimeSrv, getExploreUrl, openInNewWindow } = dependencies;
const datasourceSrv = getDataSourceSrv();
const datasource = await datasourceSrv.get(panel.datasource);
const path = await getExploreUrl({
panel,
panelTargets: panel.targets,
panelDatasource: datasource,
datasourceSrv,
timeSrv: getTimeSrv(),
});
if (openInNewWindow && path) {
openInNewWindow(path);
return;
}
const query = {}; // strips any angular query param
dispatch(updateLocation({ path, query }));
};
};
/**
* Global Explore state that handles multiple Explore areas and the split state
*/
const initialExploreItemState = makeExplorePaneState();
export const initialExploreState: ExploreState = {
split: false,
syncedTimes: false,
left: initialExploreItemState,
right: initialExploreItemState,
richHistory: [],
};
/**
* Global Explore reducer that handles multiple Explore areas (left and right).
* Actions that have an `exploreId` get routed to the ExploreItemReducer.
*/
export const exploreReducer = (state = initialExploreState, action: AnyAction): ExploreState => {
if (splitCloseAction.match(action)) {
const { itemId } = action.payload as SplitCloseActionPayload;
const targetSplit = {
left: itemId === ExploreId.left ? state.right : state.left,
right: initialExploreState.right,
};
return {
...state,
...targetSplit,
split: false,
};
}
if (splitOpenAction.match(action)) {
return { ...state, split: true, right: { ...action.payload.itemState } };
}
if (syncTimesAction.match(action)) {
return { ...state, syncedTimes: action.payload.syncedTimes };
}
if (richHistoryUpdatedAction.match(action)) {
return {
...state,
richHistory: action.payload.richHistory,
};
}
if (resetExploreAction.match(action)) {
const payload: ResetExplorePayload = action.payload;
const leftState = state[ExploreId.left];
const rightState = state[ExploreId.right];
stopQueryState(leftState.querySubscription);
stopQueryState(rightState.querySubscription);
if (payload.force || !Number.isInteger(state.left.originPanelId)) {
return initialExploreState;
}
return {
...initialExploreState,
left: {
...initialExploreItemState,
queries: state.left.queries,
originPanelId: state.left.originPanelId,
},
};
}
if (updateLocation.match(action)) {
const payload: LocationUpdate = action.payload;
const { query } = payload;
if (!query || !query[ExploreId.left]) {
return state;
}
const split = query[ExploreId.right] ? true : false;
const leftState = state[ExploreId.left];
const rightState = state[ExploreId.right];
return {
...state,
split,
[ExploreId.left]: updatePaneRefreshState(leftState, payload, ExploreId.left),
[ExploreId.right]: updatePaneRefreshState(rightState, payload, ExploreId.right),
};
}
if (action.payload) {
const { exploreId } = action.payload;
if (exploreId !== undefined) {
// @ts-ignore
const explorePaneState = state[exploreId];
return { ...state, [exploreId]: paneReducer(explorePaneState, action as any) };
}
}
return state;
};
export default {
explore: exploreReducer,
};
export const updatePaneRefreshState = (
state: Readonly<ExploreItemState>,
payload: LocationUpdate,
exploreId: ExploreId
): ExploreItemState => {
const path = payload.path || '';
if (!payload.query) {
return state;
}
const queryState = payload.query[exploreId] as string;
if (!queryState) {
return state;
}
const urlState = parseUrlState(queryState);
if (!state.urlState || path !== '/explore') {
// we only want to refresh when browser back/forward
return {
...state,
urlState,
update: { datasource: false, queries: false, range: false, mode: false },
};
}
const datasource = _.isEqual(urlState ? urlState.datasource : '', state.urlState.datasource) === false;
const queries = _.isEqual(urlState ? urlState.queries : [], state.urlState.queries) === false;
const range = _.isEqual(urlState ? urlState.range : DEFAULT_RANGE, state.urlState.range) === false;
return {
...state,
urlState,
update: {
...state.update,
datasource,
queries,
range,
},
};
};

View File

@@ -0,0 +1,164 @@
import {
cancelQueries,
cancelQueriesAction,
scanStartAction,
scanStopAction,
queryReducer,
addQueryRowAction,
removeQueryRowAction,
} from './query';
import { ExploreId, ExploreItemState } from 'app/types';
import { interval } from 'rxjs';
import { RawTimeRange, toUtc } from '@grafana/data';
import { thunkTester } from 'test/core/thunk/thunkTester';
import { makeExplorePaneState } from './utils';
import { reducerTester } from '../../../../test/core/redux/reducerTester';
const QUERY_KEY_REGEX = /Q-(?:[a-z0-9]+-){5}(?:[0-9]+)/;
const t = toUtc();
const testRange = {
from: t,
to: t,
raw: {
from: t,
to: t,
},
};
describe('running queries', () => {
it('should cancel running query when cancelQueries is dispatched', async () => {
const unsubscribable = interval(1000);
unsubscribable.subscribe();
const exploreId = ExploreId.left;
const initialState = {
explore: {
[exploreId]: {
datasourceInstance: 'test-datasource',
initialized: true,
loading: true,
querySubscription: unsubscribable,
queries: ['A'],
range: testRange,
},
},
user: {
orgId: 'A',
},
};
const dispatchedActions = await thunkTester(initialState)
.givenThunk(cancelQueries)
.whenThunkIsDispatched(exploreId);
expect(dispatchedActions).toEqual([
scanStopAction({ exploreId }),
cancelQueriesAction({ exploreId }),
expect.anything(),
]);
});
});
describe('reducer', () => {
describe('scanning', () => {
it('should start scanning', () => {
const initialState = {
...makeExplorePaneState(),
scanning: false,
};
reducerTester<ExploreItemState>()
.givenReducer(queryReducer, initialState)
.whenActionIsDispatched(scanStartAction({ exploreId: ExploreId.left }))
.thenStateShouldEqual({
...makeExplorePaneState(),
scanning: true,
});
});
it('should stop scanning', () => {
const initialState = {
...makeExplorePaneState(),
scanning: true,
scanRange: {} as RawTimeRange,
};
reducerTester<ExploreItemState>()
.givenReducer(queryReducer, initialState)
.whenActionIsDispatched(scanStopAction({ exploreId: ExploreId.left }))
.thenStateShouldEqual({
...makeExplorePaneState(),
scanning: false,
scanRange: undefined,
});
});
});
describe('query rows', () => {
it('adds a new query row', () => {
reducerTester<ExploreItemState>()
.givenReducer(queryReducer, ({
queries: [],
} as unknown) as ExploreItemState)
.whenActionIsDispatched(
addQueryRowAction({
exploreId: ExploreId.left,
query: { refId: 'A', key: 'mockKey' },
index: 0,
})
)
.thenStateShouldEqual(({
queries: [{ refId: 'A', key: 'mockKey' }],
queryKeys: ['mockKey-0'],
} as unknown) as ExploreItemState);
});
it('removes a query row', () => {
reducerTester<ExploreItemState>()
.givenReducer(queryReducer, ({
queries: [
{ refId: 'A', key: 'mockKey' },
{ refId: 'B', key: 'mockKey' },
],
queryKeys: ['mockKey-0', 'mockKey-1'],
} as unknown) as ExploreItemState)
.whenActionIsDispatched(
removeQueryRowAction({
exploreId: ExploreId.left,
index: 0,
})
)
.thenStatePredicateShouldEqual((resultingState: ExploreItemState) => {
expect(resultingState.queries.length).toBe(1);
expect(resultingState.queries[0].refId).toBe('A');
expect(resultingState.queries[0].key).toMatch(QUERY_KEY_REGEX);
expect(resultingState.queryKeys[0]).toMatch(QUERY_KEY_REGEX);
return true;
});
});
it('reassigns query refId after removing a query to keep queries in order', () => {
reducerTester<ExploreItemState>()
.givenReducer(queryReducer, ({
queries: [{ refId: 'A' }, { refId: 'B' }, { refId: 'C' }],
queryKeys: ['undefined-0', 'undefined-1', 'undefined-2'],
} as unknown) as ExploreItemState)
.whenActionIsDispatched(
removeQueryRowAction({
exploreId: ExploreId.left,
index: 0,
})
)
.thenStatePredicateShouldEqual((resultingState: ExploreItemState) => {
expect(resultingState.queries.length).toBe(2);
const queriesRefIds = resultingState.queries.map(query => query.refId);
const queriesKeys = resultingState.queries.map(query => query.key);
expect(queriesRefIds).toEqual(['A', 'B']);
queriesKeys.forEach(queryKey => {
expect(queryKey).toMatch(QUERY_KEY_REGEX);
});
resultingState.queryKeys.forEach(queryKey => {
expect(queryKey).toMatch(QUERY_KEY_REGEX);
});
return true;
});
});
});
});

View File

@@ -0,0 +1,699 @@
import { map, mergeMap, throttleTime } from 'rxjs/operators';
import { identity, Unsubscribable } from 'rxjs';
import {
DataQuery,
DataQueryErrorType,
DataSourceApi,
LoadingState,
PanelData,
PanelEvents,
QueryFixAction,
toLegacyResponseData,
} from '@grafana/data';
import {
buildQueryTransaction,
ensureQueries,
generateEmptyQuery,
generateNewKeyAndAddRefIdIfMissing,
getQueryKeys,
hasNonEmptyQuery,
stopQueryState,
updateHistory,
} from 'app/core/utils/explore';
import { addToRichHistory } from 'app/core/utils/richHistory';
import { ExploreItemState, ExplorePanelData, ThunkResult } from 'app/types';
import { ExploreId, QueryOptions } from 'app/types/explore';
import { getTimeZone } from 'app/features/profile/state/selectors';
import { getShiftedTimeRange } from 'app/core/utils/timePicker';
import { notifyApp } from '../../../core/actions';
import { preProcessPanelData, runRequest } from '../../dashboard/state/runRequest';
import {
decorateWithGraphLogsTraceAndTable,
decorateWithGraphResult,
decorateWithLogsResult,
decorateWithTableResult,
} from '../utils/decorators';
import { createErrorNotification } from '../../../core/copy/appNotification';
import { richHistoryUpdatedAction } from './main';
import { stateSave } from './explorePane';
import { AnyAction, createAction, PayloadAction } from '@reduxjs/toolkit';
import { updateTime } from './time';
import { historyUpdatedAction } from './history';
import { createEmptyQueryResponse, makeInitialUpdateState } from './utils';
//
// Actions and Payloads
//
/**
* Adds a query row after the row with the given index.
*/
export interface AddQueryRowPayload {
exploreId: ExploreId;
index: number;
query: DataQuery;
}
export const addQueryRowAction = createAction<AddQueryRowPayload>('explore/addQueryRow');
/**
* Remove query row of the given index, as well as associated query results.
*/
export interface RemoveQueryRowPayload {
exploreId: ExploreId;
index: number;
}
export const removeQueryRowAction = createAction<RemoveQueryRowPayload>('explore/removeQueryRow');
/**
* Query change handler for the query row with the given index.
* If `override` is reset the query modifications and run the queries. Use this to set queries via a link.
*/
export interface ChangeQueryPayload {
exploreId: ExploreId;
query: DataQuery;
index: number;
override: boolean;
}
export const changeQueryAction = createAction<ChangeQueryPayload>('explore/changeQuery');
/**
* Clear all queries and results.
*/
export interface ClearQueriesPayload {
exploreId: ExploreId;
}
export const clearQueriesAction = createAction<ClearQueriesPayload>('explore/clearQueries');
/**
* Cancel running queries.
*/
export const cancelQueriesAction = createAction<ClearQueriesPayload>('explore/cancelQueries');
export interface QueriesImportedPayload {
exploreId: ExploreId;
queries: DataQuery[];
}
export const queriesImportedAction = createAction<QueriesImportedPayload>('explore/queriesImported');
/**
* Action to modify a query given a datasource-specific modifier action.
* @param exploreId Explore area
* @param modification Action object with a type, e.g., ADD_FILTER
* @param index Optional query row index. If omitted, the modification is applied to all query rows.
* @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`.
*/
export interface ModifyQueriesPayload {
exploreId: ExploreId;
modification: QueryFixAction;
index?: number;
modifier: (query: DataQuery, modification: QueryFixAction) => DataQuery;
}
export const modifyQueriesAction = createAction<ModifyQueriesPayload>('explore/modifyQueries');
export interface QueryStoreSubscriptionPayload {
exploreId: ExploreId;
querySubscription: Unsubscribable;
}
export const queryStoreSubscriptionAction = createAction<QueryStoreSubscriptionPayload>(
'explore/queryStoreSubscription'
);
export interface QueryEndedPayload {
exploreId: ExploreId;
response: ExplorePanelData;
}
export const queryStreamUpdatedAction = createAction<QueryEndedPayload>('explore/queryStreamUpdated');
/**
* Reset queries to the given queries. Any modifications will be discarded.
* Use this action for clicks on query examples. Triggers a query run.
*/
export interface SetQueriesPayload {
exploreId: ExploreId;
queries: DataQuery[];
}
export const setQueriesAction = createAction<SetQueriesPayload>('explore/setQueries');
export interface ChangeLoadingStatePayload {
exploreId: ExploreId;
loadingState: LoadingState;
}
export const changeLoadingStateAction = createAction<ChangeLoadingStatePayload>('changeLoadingState');
export interface SetPausedStatePayload {
exploreId: ExploreId;
isPaused: boolean;
}
export const setPausedStateAction = createAction<SetPausedStatePayload>('explore/setPausedState');
/**
* Start a scan for more results using the given scanner.
* @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 interface ScanStartPayload {
exploreId: ExploreId;
}
export const scanStartAction = createAction<ScanStartPayload>('explore/scanStart');
/**
* Stop any scanning for more results.
*/
export interface ScanStopPayload {
exploreId: ExploreId;
}
export const scanStopAction = createAction<ScanStopPayload>('explore/scanStop');
//
// Action creators
//
/**
* Adds a query row after the row with the given index.
*/
export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult<void> {
return (dispatch, getState) => {
const queries = getState().explore[exploreId].queries;
const query = generateEmptyQuery(queries, index);
dispatch(addQueryRowAction({ exploreId, index, query }));
};
}
/**
* Query change handler for the query row with the given index.
* If `override` is reset the query modifications and run the queries. Use this to set queries via a link.
*/
export function changeQuery(
exploreId: ExploreId,
query: DataQuery,
index: number,
override = false
): ThunkResult<void> {
return (dispatch, getState) => {
// Null query means reset
if (query === null) {
const queries = getState().explore[exploreId].queries;
const { refId, key } = queries[index];
query = generateNewKeyAndAddRefIdIfMissing({ refId, key }, queries, index);
}
dispatch(changeQueryAction({ exploreId, query, index, override }));
if (override) {
dispatch(runQueries(exploreId));
}
};
}
/**
* Clear all queries and results.
*/
export function clearQueries(exploreId: ExploreId): ThunkResult<void> {
return dispatch => {
dispatch(scanStopAction({ exploreId }));
dispatch(clearQueriesAction({ exploreId }));
dispatch(stateSave());
};
}
/**
* Cancel running queries
*/
export function cancelQueries(exploreId: ExploreId): ThunkResult<void> {
return dispatch => {
dispatch(scanStopAction({ exploreId }));
dispatch(cancelQueriesAction({ exploreId }));
dispatch(stateSave());
};
}
/**
* Import queries from previous datasource if possible eg Loki and Prometheus have similar query language so the
* labels part can be reused to get similar data.
* @param exploreId
* @param queries
* @param sourceDataSource
* @param targetDataSource
*/
export const importQueries = (
exploreId: ExploreId,
queries: DataQuery[],
sourceDataSource: DataSourceApi | undefined | null,
targetDataSource: DataSourceApi
): ThunkResult<void> => {
return async dispatch => {
if (!sourceDataSource) {
// explore not initialized
dispatch(queriesImportedAction({ exploreId, queries }));
return;
}
let importedQueries = queries;
// Check if queries can be imported from previously selected datasource
if (sourceDataSource.meta?.id === targetDataSource.meta?.id) {
// Keep same queries if same type of datasource
importedQueries = [...queries];
} else if (targetDataSource.importQueries) {
// Datasource-specific importers
importedQueries = await targetDataSource.importQueries(queries, sourceDataSource.meta);
} else {
// Default is blank queries
importedQueries = ensureQueries();
}
const nextQueries = ensureQueries(importedQueries);
dispatch(queriesImportedAction({ exploreId, queries: nextQueries }));
};
};
/**
* Action to modify a query given a datasource-specific modifier action.
* @param exploreId Explore area
* @param modification Action object with a type, e.g., ADD_FILTER
* @param index Optional query row index. If omitted, the modification is applied to all query rows.
* @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`.
*/
export function modifyQueries(
exploreId: ExploreId,
modification: QueryFixAction,
modifier: any,
index?: number
): ThunkResult<void> {
return dispatch => {
dispatch(modifyQueriesAction({ exploreId, modification, index, modifier }));
if (!modification.preventSubmit) {
dispatch(runQueries(exploreId));
}
};
}
/**
* Main action to run queries and dispatches sub-actions based on which result viewers are active
*/
export const runQueries = (exploreId: ExploreId): ThunkResult<void> => {
return (dispatch, getState) => {
dispatch(updateTime({ exploreId }));
const richHistory = getState().explore.richHistory;
const exploreItemState = getState().explore[exploreId];
const {
datasourceInstance,
queries,
containerWidth,
isLive: live,
range,
scanning,
queryResponse,
querySubscription,
history,
refreshInterval,
absoluteRange,
} = exploreItemState;
if (!hasNonEmptyQuery(queries)) {
dispatch(clearQueriesAction({ exploreId }));
dispatch(stateSave()); // Remember to save to state and update location
return;
}
if (!datasourceInstance) {
return;
}
// Some datasource's query builders allow per-query interval limits,
// but we're using the datasource interval limit for now
const minInterval = datasourceInstance?.interval;
stopQueryState(querySubscription);
const datasourceId = datasourceInstance?.meta.id;
const queryOptions: QueryOptions = {
minInterval,
// maxDataPoints is used in:
// Loki - used for logs streaming for buffer size, with undefined it falls back to datasource config if it supports that.
// Elastic - limits the number of datapoints for the counts query and for logs it has hardcoded limit.
// Influx - used to correctly display logs in graph
// TODO:unification
// maxDataPoints: mode === ExploreMode.Logs && datasourceId === 'loki' ? undefined : containerWidth,
maxDataPoints: containerWidth,
liveStreaming: live,
};
const datasourceName = exploreItemState.requestedDatasourceName;
const timeZone = getTimeZone(getState().user);
const transaction = buildQueryTransaction(queries, queryOptions, range, scanning, timeZone);
let firstResponse = true;
dispatch(changeLoadingStateAction({ exploreId, loadingState: LoadingState.Loading }));
const newQuerySub = runRequest(datasourceInstance, transaction.request)
.pipe(
// Simple throttle for live tailing, in case of > 1000 rows per interval we spend about 200ms on processing and
// rendering. In case this is optimized this can be tweaked, but also it should be only as fast as user
// actually can see what is happening.
live ? throttleTime(500) : identity,
map((data: PanelData) => preProcessPanelData(data, queryResponse)),
map(decorateWithGraphLogsTraceAndTable),
map(decorateWithGraphResult),
map(decorateWithLogsResult({ absoluteRange, refreshInterval })),
mergeMap(decorateWithTableResult)
)
.subscribe(
data => {
if (!data.error && firstResponse) {
// Side-effect: Saving history in localstorage
const nextHistory = updateHistory(history, datasourceId, queries);
const nextRichHistory = addToRichHistory(
richHistory || [],
datasourceId,
datasourceName,
queries,
false,
'',
''
);
dispatch(historyUpdatedAction({ exploreId, history: nextHistory }));
dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory }));
// We save queries to the URL here so that only successfully run queries change the URL.
dispatch(stateSave());
}
firstResponse = false;
dispatch(queryStreamUpdatedAction({ exploreId, response: data }));
// Keep scanning for results if this was the last scanning transaction
if (getState().explore[exploreId].scanning) {
if (data.state === LoadingState.Done && data.series.length === 0) {
const range = getShiftedTimeRange(-1, getState().explore[exploreId].range);
dispatch(updateTime({ exploreId, absoluteRange: range }));
dispatch(runQueries(exploreId));
} else {
// We can stop scanning if we have a result
dispatch(scanStopAction({ exploreId }));
}
}
},
error => {
dispatch(notifyApp(createErrorNotification('Query processing error', error)));
dispatch(changeLoadingStateAction({ exploreId, loadingState: LoadingState.Error }));
console.error(error);
}
);
dispatch(queryStoreSubscriptionAction({ exploreId, querySubscription: newQuerySub }));
};
};
/**
* 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> {
return (dispatch, getState) => {
// Inject react keys into query objects
const queries = getState().explore[exploreId].queries;
const nextQueries = rawQueries.map((query, index) => generateNewKeyAndAddRefIdIfMissing(query, queries, index));
dispatch(setQueriesAction({ exploreId, queries: nextQueries }));
dispatch(runQueries(exploreId));
};
}
/**
* Start a scan for more results using the given scanner.
* @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> {
return (dispatch, getState) => {
// Register the scanner
dispatch(scanStartAction({ exploreId }));
// Scanning must trigger query run, and return the new range
const range = getShiftedTimeRange(-1, getState().explore[exploreId].range);
// Set the new range to be displayed
dispatch(updateTime({ exploreId, absoluteRange: range }));
dispatch(runQueries(exploreId));
};
}
//
// Reducer
//
// Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated.
// ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice
// because the state would become frozen and during run time we would get errors because flot (Graph lib) would try to mutate
// the frozen state.
// https://github.com/reduxjs/redux-toolkit/issues/242
export const queryReducer = (state: ExploreItemState, action: AnyAction): ExploreItemState => {
if (addQueryRowAction.match(action)) {
const { queries } = state;
const { index, query } = action.payload;
// Add to queries, which will cause a new row to be rendered
const nextQueries = [...queries.slice(0, index + 1), { ...query }, ...queries.slice(index + 1)];
return {
...state,
queries: nextQueries,
logsHighlighterExpressions: undefined,
queryKeys: getQueryKeys(nextQueries, state.datasourceInstance),
};
}
if (changeQueryAction.match(action)) {
const { queries } = state;
const { query, index } = action.payload;
// Override path: queries are completely reset
const nextQuery: DataQuery = generateNewKeyAndAddRefIdIfMissing(query, queries, index);
const nextQueries = [...queries];
nextQueries[index] = nextQuery;
return {
...state,
queries: nextQueries,
queryKeys: getQueryKeys(nextQueries, state.datasourceInstance),
};
}
if (clearQueriesAction.match(action)) {
const queries = ensureQueries();
stopQueryState(state.querySubscription);
return {
...state,
queries: queries.slice(),
graphResult: null,
tableResult: null,
logsResult: null,
queryKeys: getQueryKeys(queries, state.datasourceInstance),
queryResponse: createEmptyQueryResponse(),
loading: false,
};
}
if (cancelQueriesAction.match(action)) {
stopQueryState(state.querySubscription);
return {
...state,
loading: false,
};
}
if (modifyQueriesAction.match(action)) {
const { queries } = state;
const { modification, index, modifier } = action.payload;
let nextQueries: DataQuery[];
if (index === undefined) {
// Modify all queries
nextQueries = queries.map((query, i) => {
const nextQuery = modifier({ ...query }, modification);
return generateNewKeyAndAddRefIdIfMissing(nextQuery, queries, i);
});
} else {
// Modify query only at index
nextQueries = queries.map((query, i) => {
if (i === index) {
const nextQuery = modifier({ ...query }, modification);
return generateNewKeyAndAddRefIdIfMissing(nextQuery, queries, i);
}
return query;
});
}
return {
...state,
queries: nextQueries,
queryKeys: getQueryKeys(nextQueries, state.datasourceInstance),
};
}
if (removeQueryRowAction.match(action)) {
const { queries } = state;
const { index } = action.payload;
if (queries.length <= 1) {
return state;
}
// removes a query under a given index and reassigns query keys and refIds to keep everything in order
const queriesAfterRemoval: DataQuery[] = [...queries.slice(0, index), ...queries.slice(index + 1)].map(query => {
return { ...query, refId: '' };
});
const nextQueries: DataQuery[] = [];
queriesAfterRemoval.forEach((query, i) => {
nextQueries.push(generateNewKeyAndAddRefIdIfMissing(query, nextQueries, i));
});
const nextQueryKeys: string[] = nextQueries.map(query => query.key!);
return {
...state,
queries: nextQueries,
logsHighlighterExpressions: undefined,
queryKeys: nextQueryKeys,
};
}
if (setQueriesAction.match(action)) {
const { queries } = action.payload;
return {
...state,
queries: queries.slice(),
queryKeys: getQueryKeys(queries, state.datasourceInstance),
};
}
if (queriesImportedAction.match(action)) {
const { queries } = action.payload;
return {
...state,
queries,
queryKeys: getQueryKeys(queries, state.datasourceInstance),
};
}
if (queryStoreSubscriptionAction.match(action)) {
const { querySubscription } = action.payload;
return {
...state,
querySubscription,
};
}
if (queryStreamUpdatedAction.match(action)) {
return processQueryResponse(state, action);
}
if (queriesImportedAction.match(action)) {
const { queries } = action.payload;
return {
...state,
queries,
queryKeys: getQueryKeys(queries, state.datasourceInstance),
};
}
if (changeLoadingStateAction.match(action)) {
const { loadingState } = action.payload;
return {
...state,
queryResponse: {
...state.queryResponse,
state: loadingState,
},
loading: loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming,
};
}
if (setPausedStateAction.match(action)) {
const { isPaused } = action.payload;
return {
...state,
isPaused: isPaused,
};
}
if (scanStartAction.match(action)) {
return { ...state, scanning: true };
}
if (scanStopAction.match(action)) {
return {
...state,
scanning: false,
scanRange: undefined,
update: makeInitialUpdateState(),
};
}
return state;
};
export const processQueryResponse = (
state: ExploreItemState,
action: PayloadAction<QueryEndedPayload>
): ExploreItemState => {
const { response } = action.payload;
const { request, state: loadingState, series, error, graphResult, logsResult, tableResult, traceFrames } = response;
if (error) {
if (error.type === DataQueryErrorType.Timeout) {
return {
...state,
queryResponse: response,
loading: loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming,
};
} else if (error.type === DataQueryErrorType.Cancelled) {
return state;
}
// For Angular editors
state.eventBridge.emit(PanelEvents.dataError, error);
return {
...state,
loading: false,
queryResponse: response,
graphResult: null,
tableResult: null,
logsResult: null,
update: makeInitialUpdateState(),
};
}
if (!request) {
return { ...state };
}
const latency = request.endTime ? request.endTime - request.startTime : 0;
// Send legacy data to Angular editors
if (state.datasourceInstance?.components?.QueryCtrl) {
const legacy = series.map(v => toLegacyResponseData(v));
state.eventBridge.emit(PanelEvents.dataReceived, legacy);
}
return {
...state,
latency,
queryResponse: response,
graphResult,
tableResult,
logsResult,
loading: loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming,
update: makeInitialUpdateState(),
showLogs: !!logsResult,
showMetrics: !!graphResult,
showTable: !!tableResult,
showTrace: !!traceFrames.length,
};
};

View File

@@ -1,649 +0,0 @@
import _ from 'lodash';
import { AnyAction } from 'redux';
import { PayloadAction } from '@reduxjs/toolkit';
import {
DataQuery,
DataQueryErrorType,
DefaultTimeRange,
LoadingState,
LogsDedupStrategy,
PanelData,
PanelEvents,
sortLogsResult,
toLegacyResponseData,
EventBusExtended,
} from '@grafana/data';
import { RefreshPicker } from '@grafana/ui';
import { LocationUpdate } from '@grafana/runtime';
import {
ensureQueries,
generateNewKeyAndAddRefIdIfMissing,
getQueryKeys,
parseUrlState,
refreshIntervalToSortOrder,
stopQueryState,
} from 'app/core/utils/explore';
import { ExploreId, ExploreItemState, ExploreState, ExploreUpdateState } from 'app/types/explore';
import {
addQueryRowAction,
cancelQueriesAction,
changeDedupStrategyAction,
changeLoadingStateAction,
changeQueryAction,
changeRangeAction,
changeRefreshIntervalAction,
changeSizeAction,
clearQueriesAction,
highlightLogsExpressionAction,
historyUpdatedAction,
initializeExploreAction,
loadDatasourceMissingAction,
loadDatasourcePendingAction,
loadDatasourceReadyAction,
modifyQueriesAction,
queriesImportedAction,
QueryEndedPayload,
queryStoreSubscriptionAction,
queryStreamUpdatedAction,
removeQueryRowAction,
resetExploreAction,
ResetExplorePayload,
richHistoryUpdatedAction,
scanStartAction,
scanStopAction,
setPausedStateAction,
setQueriesAction,
setUrlReplacedAction,
splitCloseAction,
SplitCloseActionPayload,
splitOpenAction,
syncTimesAction,
toggleLogLevelAction,
updateDatasourceInstanceAction,
} from './actionTypes';
import { updateLocation } from '../../../core/actions';
export const DEFAULT_RANGE = {
from: 'now-6h',
to: 'now',
};
export const makeInitialUpdateState = (): ExploreUpdateState => ({
datasource: false,
queries: false,
range: false,
mode: false,
});
/**
* Returns a fresh Explore area state
*/
export const makeExploreItemState = (): ExploreItemState => ({
containerWidth: 0,
datasourceInstance: null,
requestedDatasourceName: null,
datasourceLoading: null,
datasourceMissing: false,
history: [],
queries: [],
initialized: false,
range: {
from: null,
to: null,
raw: DEFAULT_RANGE,
} as any,
absoluteRange: {
from: null,
to: null,
} as any,
scanning: false,
loading: false,
queryKeys: [],
urlState: null,
update: makeInitialUpdateState(),
latency: 0,
isLive: false,
isPaused: false,
urlReplaced: false,
queryResponse: createEmptyQueryResponse(),
tableResult: null,
graphResult: null,
logsResult: null,
dedupStrategy: LogsDedupStrategy.none,
eventBridge: (null as unknown) as EventBusExtended,
});
export const createEmptyQueryResponse = (): PanelData => ({
state: LoadingState.NotStarted,
series: [],
timeRange: DefaultTimeRange,
});
/**
* Global Explore state that handles multiple Explore areas and the split state
*/
export const initialExploreItemState = makeExploreItemState();
export const initialExploreState: ExploreState = {
split: false,
syncedTimes: false,
left: initialExploreItemState,
right: initialExploreItemState,
richHistory: [],
};
/**
* Reducer for an Explore area, to be used by the global Explore reducer.
*/
// Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated.
// ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice
// because the state would become frozen and during run time we would get errors because flot (Graph lib) would try to mutate
// the frozen state.
// https://github.com/reduxjs/redux-toolkit/issues/242
export const itemReducer = (state: ExploreItemState = makeExploreItemState(), action: AnyAction): ExploreItemState => {
if (addQueryRowAction.match(action)) {
const { queries } = state;
const { index, query } = action.payload;
// Add to queries, which will cause a new row to be rendered
const nextQueries = [...queries.slice(0, index + 1), { ...query }, ...queries.slice(index + 1)];
return {
...state,
queries: nextQueries,
logsHighlighterExpressions: undefined,
queryKeys: getQueryKeys(nextQueries, state.datasourceInstance),
};
}
if (changeQueryAction.match(action)) {
const { queries } = state;
const { query, index } = action.payload;
// Override path: queries are completely reset
const nextQuery: DataQuery = generateNewKeyAndAddRefIdIfMissing(query, queries, index);
const nextQueries = [...queries];
nextQueries[index] = nextQuery;
return {
...state,
queries: nextQueries,
queryKeys: getQueryKeys(nextQueries, state.datasourceInstance),
};
}
if (changeSizeAction.match(action)) {
const containerWidth = action.payload.width;
return { ...state, containerWidth };
}
if (changeRefreshIntervalAction.match(action)) {
const { refreshInterval } = action.payload;
const live = RefreshPicker.isLive(refreshInterval);
const sortOrder = refreshIntervalToSortOrder(refreshInterval);
const logsResult = sortLogsResult(state.logsResult, sortOrder);
if (RefreshPicker.isLive(state.refreshInterval) && !live) {
stopQueryState(state.querySubscription);
}
return {
...state,
refreshInterval,
queryResponse: {
...state.queryResponse,
state: live ? LoadingState.Streaming : LoadingState.Done,
},
isLive: live,
isPaused: live ? false : state.isPaused,
loading: live,
logsResult,
};
}
if (clearQueriesAction.match(action)) {
const queries = ensureQueries();
stopQueryState(state.querySubscription);
return {
...state,
queries: queries.slice(),
graphResult: null,
tableResult: null,
logsResult: null,
queryKeys: getQueryKeys(queries, state.datasourceInstance),
queryResponse: createEmptyQueryResponse(),
loading: false,
};
}
if (cancelQueriesAction.match(action)) {
stopQueryState(state.querySubscription);
return {
...state,
loading: false,
};
}
if (highlightLogsExpressionAction.match(action)) {
const { expressions } = action.payload;
return { ...state, logsHighlighterExpressions: expressions };
}
if (changeDedupStrategyAction.match(action)) {
const { dedupStrategy } = action.payload;
return {
...state,
dedupStrategy,
};
}
if (initializeExploreAction.match(action)) {
const { containerWidth, eventBridge, queries, range, originPanelId } = action.payload;
return {
...state,
containerWidth,
eventBridge,
range,
queries,
initialized: true,
queryKeys: getQueryKeys(queries, state.datasourceInstance),
originPanelId,
update: makeInitialUpdateState(),
};
}
if (updateDatasourceInstanceAction.match(action)) {
const { datasourceInstance } = action.payload;
// Custom components
stopQueryState(state.querySubscription);
return {
...state,
datasourceInstance,
graphResult: null,
tableResult: null,
logsResult: null,
latency: 0,
queryResponse: createEmptyQueryResponse(),
loading: false,
queryKeys: [],
originPanelId: state.urlState && state.urlState.originPanelId,
};
}
if (loadDatasourceMissingAction.match(action)) {
return {
...state,
datasourceMissing: true,
datasourceLoading: false,
update: makeInitialUpdateState(),
};
}
if (loadDatasourcePendingAction.match(action)) {
return {
...state,
datasourceLoading: true,
requestedDatasourceName: action.payload.requestedDatasourceName,
};
}
if (loadDatasourceReadyAction.match(action)) {
const { history } = action.payload;
return {
...state,
history,
datasourceLoading: false,
datasourceMissing: false,
logsHighlighterExpressions: undefined,
update: makeInitialUpdateState(),
};
}
if (modifyQueriesAction.match(action)) {
const { queries } = state;
const { modification, index, modifier } = action.payload;
let nextQueries: DataQuery[];
if (index === undefined) {
// Modify all queries
nextQueries = queries.map((query, i) => {
const nextQuery = modifier({ ...query }, modification);
return generateNewKeyAndAddRefIdIfMissing(nextQuery, queries, i);
});
} else {
// Modify query only at index
nextQueries = queries.map((query, i) => {
if (i === index) {
const nextQuery = modifier({ ...query }, modification);
return generateNewKeyAndAddRefIdIfMissing(nextQuery, queries, i);
}
return query;
});
}
return {
...state,
queries: nextQueries,
queryKeys: getQueryKeys(nextQueries, state.datasourceInstance),
};
}
if (removeQueryRowAction.match(action)) {
const { queries } = state;
const { index } = action.payload;
if (queries.length <= 1) {
return state;
}
// removes a query under a given index and reassigns query keys and refIds to keep everything in order
const queriesAfterRemoval: DataQuery[] = [...queries.slice(0, index), ...queries.slice(index + 1)].map(query => {
return { ...query, refId: '' };
});
const nextQueries: DataQuery[] = [];
queriesAfterRemoval.forEach((query, i) => {
nextQueries.push(generateNewKeyAndAddRefIdIfMissing(query, nextQueries, i));
});
const nextQueryKeys: string[] = nextQueries.map(query => query.key!);
return {
...state,
queries: nextQueries,
logsHighlighterExpressions: undefined,
queryKeys: nextQueryKeys,
};
}
if (scanStartAction.match(action)) {
return { ...state, scanning: true };
}
if (scanStopAction.match(action)) {
return {
...state,
scanning: false,
scanRange: undefined,
update: makeInitialUpdateState(),
};
}
if (setQueriesAction.match(action)) {
const { queries } = action.payload;
return {
...state,
queries: queries.slice(),
queryKeys: getQueryKeys(queries, state.datasourceInstance),
};
}
if (queriesImportedAction.match(action)) {
const { queries } = action.payload;
return {
...state,
queries,
queryKeys: getQueryKeys(queries, state.datasourceInstance),
};
}
if (toggleLogLevelAction.match(action)) {
const { hiddenLogLevels } = action.payload;
return {
...state,
hiddenLogLevels: Array.from(hiddenLogLevels),
};
}
if (historyUpdatedAction.match(action)) {
return {
...state,
history: action.payload.history,
};
}
if (setUrlReplacedAction.match(action)) {
return {
...state,
urlReplaced: true,
};
}
if (changeRangeAction.match(action)) {
const { range, absoluteRange } = action.payload;
return {
...state,
range,
absoluteRange,
update: makeInitialUpdateState(),
};
}
if (changeLoadingStateAction.match(action)) {
const { loadingState } = action.payload;
return {
...state,
queryResponse: {
...state.queryResponse,
state: loadingState,
},
loading: loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming,
};
}
if (setPausedStateAction.match(action)) {
const { isPaused } = action.payload;
return {
...state,
isPaused: isPaused,
};
}
if (queryStoreSubscriptionAction.match(action)) {
const { querySubscription } = action.payload;
return {
...state,
querySubscription,
};
}
if (queryStreamUpdatedAction.match(action)) {
return processQueryResponse(state, action);
}
return state;
};
export const processQueryResponse = (
state: ExploreItemState,
action: PayloadAction<QueryEndedPayload>
): ExploreItemState => {
const { response } = action.payload;
const { request, state: loadingState, series, error, graphResult, logsResult, tableResult, traceFrames } = response;
if (error) {
if (error.type === DataQueryErrorType.Timeout) {
return {
...state,
queryResponse: response,
loading: loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming,
};
} else if (error.type === DataQueryErrorType.Cancelled) {
return state;
}
// For Angular editors
state.eventBridge.emit(PanelEvents.dataError, error);
return {
...state,
loading: false,
queryResponse: response,
graphResult: null,
tableResult: null,
logsResult: null,
update: makeInitialUpdateState(),
};
}
if (!request) {
return { ...state };
}
const latency = request.endTime ? request.endTime - request.startTime : 0;
// Send legacy data to Angular editors
if (state.datasourceInstance?.components?.QueryCtrl) {
const legacy = series.map(v => toLegacyResponseData(v));
state.eventBridge.emit(PanelEvents.dataReceived, legacy);
}
return {
...state,
latency,
queryResponse: response,
graphResult,
tableResult,
logsResult,
loading: loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming,
update: makeInitialUpdateState(),
showLogs: !!logsResult,
showMetrics: !!graphResult,
showTable: !!tableResult,
showTrace: !!traceFrames.length,
};
};
export const updateChildRefreshState = (
state: Readonly<ExploreItemState>,
payload: LocationUpdate,
exploreId: ExploreId
): ExploreItemState => {
const path = payload.path || '';
if (!payload.query) {
return state;
}
const queryState = payload.query[exploreId] as string;
if (!queryState) {
return state;
}
const urlState = parseUrlState(queryState);
if (!state.urlState || path !== '/explore') {
// we only want to refresh when browser back/forward
return {
...state,
urlState,
update: { datasource: false, queries: false, range: false, mode: false },
};
}
const datasource = _.isEqual(urlState ? urlState.datasource : '', state.urlState.datasource) === false;
const queries = _.isEqual(urlState ? urlState.queries : [], state.urlState.queries) === false;
const range = _.isEqual(urlState ? urlState.range : DEFAULT_RANGE, state.urlState.range) === false;
return {
...state,
urlState,
update: {
...state.update,
datasource,
queries,
range,
},
};
};
/**
* Global Explore reducer that handles multiple Explore areas (left and right).
* Actions that have an `exploreId` get routed to the ExploreItemReducer.
*/
export const exploreReducer = (state = initialExploreState, action: AnyAction): ExploreState => {
if (splitCloseAction.match(action)) {
const { itemId } = action.payload as SplitCloseActionPayload;
const targetSplit = {
left: itemId === ExploreId.left ? state.right : state.left,
right: initialExploreState.right,
};
return {
...state,
...targetSplit,
split: false,
};
}
if (splitOpenAction.match(action)) {
return { ...state, split: true, right: { ...action.payload.itemState } };
}
if (syncTimesAction.match(action)) {
return { ...state, syncedTimes: action.payload.syncedTimes };
}
if (richHistoryUpdatedAction.match(action)) {
return {
...state,
richHistory: action.payload.richHistory,
};
}
if (resetExploreAction.match(action)) {
const payload: ResetExplorePayload = action.payload;
const leftState = state[ExploreId.left];
const rightState = state[ExploreId.right];
stopQueryState(leftState.querySubscription);
stopQueryState(rightState.querySubscription);
if (payload.force || !Number.isInteger(state.left.originPanelId)) {
return initialExploreState;
}
return {
...initialExploreState,
left: {
...initialExploreItemState,
queries: state.left.queries,
originPanelId: state.left.originPanelId,
},
};
}
if (updateLocation.match(action)) {
const payload: LocationUpdate = action.payload;
const { query } = payload;
if (!query || !query[ExploreId.left]) {
return state;
}
const split = query[ExploreId.right] ? true : false;
const leftState = state[ExploreId.left];
const rightState = state[ExploreId.right];
return {
...state,
split,
[ExploreId.left]: updateChildRefreshState(leftState, payload, ExploreId.left),
[ExploreId.right]: updateChildRefreshState(rightState, payload, ExploreId.right),
};
}
if (action.payload) {
const { exploreId } = action.payload;
if (exploreId !== undefined) {
// @ts-ignore
const exploreItemState = state[exploreId];
return { ...state, [exploreId]: itemReducer(exploreItemState, action as any) };
}
}
return state;
};
export default {
explore: exploreReducer,
};

View File

@@ -0,0 +1,77 @@
import { dateTime, LoadingState } from '@grafana/data';
import { makeExplorePaneState, makeInitialUpdateState } from './utils';
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { reducerTester } from 'test/core/redux/reducerTester';
import { changeRangeAction, changeRefreshIntervalAction, timeReducer } from './time';
describe('Explore item reducer', () => {
describe('changing refresh intervals', () => {
it("should result in 'streaming' state, when live-tailing is active", () => {
const initialState = makeExplorePaneState();
const expectedState = {
...makeExplorePaneState(),
refreshInterval: 'LIVE',
isLive: true,
loading: true,
logsResult: {
hasUniqueLabels: false,
rows: [] as any[],
},
queryResponse: {
...makeExplorePaneState().queryResponse,
state: LoadingState.Streaming,
},
};
reducerTester<ExploreItemState>()
.givenReducer(timeReducer, initialState)
.whenActionIsDispatched(changeRefreshIntervalAction({ exploreId: ExploreId.left, refreshInterval: 'LIVE' }))
.thenStateShouldEqual(expectedState);
});
it("should result in 'done' state, when live-tailing is stopped", () => {
const initialState = makeExplorePaneState();
const expectedState = {
...makeExplorePaneState(),
refreshInterval: '',
logsResult: {
hasUniqueLabels: false,
rows: [] as any[],
},
queryResponse: {
...makeExplorePaneState().queryResponse,
state: LoadingState.Done,
},
};
reducerTester<ExploreItemState>()
.givenReducer(timeReducer, initialState)
.whenActionIsDispatched(changeRefreshIntervalAction({ exploreId: ExploreId.left, refreshInterval: '' }))
.thenStateShouldEqual(expectedState);
});
});
describe('changing range', () => {
describe('when changeRangeAction is dispatched', () => {
it('then it should set correct state', () => {
reducerTester<ExploreItemState>()
.givenReducer(timeReducer, ({
update: { ...makeInitialUpdateState(), range: true },
range: null,
absoluteRange: null,
} as unknown) as ExploreItemState)
.whenActionIsDispatched(
changeRangeAction({
exploreId: ExploreId.left,
absoluteRange: { from: 1546297200000, to: 1546383600000 },
range: { from: dateTime('2019-01-01'), to: dateTime('2019-01-02'), raw: { from: 'now-1d', to: 'now' } },
})
)
.thenStateShouldEqual(({
update: { ...makeInitialUpdateState(), range: false },
absoluteRange: { from: 1546297200000, to: 1546383600000 },
range: { from: dateTime('2019-01-01'), to: dateTime('2019-01-02'), raw: { from: 'now-1d', to: 'now' } },
} as unknown) as ExploreItemState);
});
});
});
});

View File

@@ -0,0 +1,173 @@
import { AnyAction, createAction, PayloadAction } from '@reduxjs/toolkit';
import {
AbsoluteTimeRange,
dateTimeForTimeZone,
LoadingState,
RawTimeRange,
sortLogsResult,
TimeRange,
} from '@grafana/data';
import { RefreshPicker } from '@grafana/ui';
import { getTimeRange, refreshIntervalToSortOrder, stopQueryState } from 'app/core/utils/explore';
import { ExploreItemState, ThunkResult } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { getTimeZone } from 'app/features/profile/state/selectors';
import { getTimeSrv } from '../../dashboard/services/TimeSrv';
import { DashboardModel } from 'app/features/dashboard/state';
import { runQueries } from './query';
import { syncTimesAction } from './main';
import { stateSave } from './explorePane';
import { makeInitialUpdateState } from './utils';
//
// Actions and Payloads
//
export interface ChangeRangePayload {
exploreId: ExploreId;
range: TimeRange;
absoluteRange: AbsoluteTimeRange;
}
export const changeRangeAction = createAction<ChangeRangePayload>('explore/changeRange');
/**
* Change the time range of Explore. Usually called from the Timepicker or a graph interaction.
*/
export interface ChangeRefreshIntervalPayload {
exploreId: ExploreId;
refreshInterval: string;
}
export const changeRefreshIntervalAction = createAction<ChangeRefreshIntervalPayload>('explore/changeRefreshInterval');
export const updateTimeRange = (options: {
exploreId: ExploreId;
rawRange?: RawTimeRange;
absoluteRange?: AbsoluteTimeRange;
}): ThunkResult<void> => {
return (dispatch, getState) => {
const { syncedTimes } = getState().explore;
if (syncedTimes) {
dispatch(updateTime({ ...options, exploreId: ExploreId.left }));
dispatch(runQueries(ExploreId.left));
dispatch(updateTime({ ...options, exploreId: ExploreId.right }));
dispatch(runQueries(ExploreId.right));
} else {
dispatch(updateTime({ ...options }));
dispatch(runQueries(options.exploreId));
}
};
};
/**
* Change the refresh interval of Explore. Called from the Refresh picker.
*/
export function changeRefreshInterval(
exploreId: ExploreId,
refreshInterval: string
): PayloadAction<ChangeRefreshIntervalPayload> {
return changeRefreshIntervalAction({ exploreId, refreshInterval });
}
export const updateTime = (config: {
exploreId: ExploreId;
rawRange?: RawTimeRange;
absoluteRange?: AbsoluteTimeRange;
}): ThunkResult<void> => {
return (dispatch, getState) => {
const { exploreId, absoluteRange: absRange, rawRange: actionRange } = config;
const itemState = getState().explore[exploreId];
const timeZone = getTimeZone(getState().user);
const { range: rangeInState } = itemState;
let rawRange: RawTimeRange = rangeInState.raw;
if (absRange) {
rawRange = {
from: dateTimeForTimeZone(timeZone, absRange.from),
to: dateTimeForTimeZone(timeZone, absRange.to),
};
}
if (actionRange) {
rawRange = actionRange;
}
const range = getTimeRange(timeZone, rawRange);
const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
getTimeSrv().init(
new DashboardModel({
time: range.raw,
refresh: false,
timeZone,
})
);
dispatch(changeRangeAction({ exploreId, range, absoluteRange }));
};
};
/**
* 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> {
return (dispatch, getState) => {
if (exploreId === ExploreId.left) {
const leftState = getState().explore.left;
dispatch(updateTimeRange({ exploreId: ExploreId.right, rawRange: leftState.range.raw }));
} else {
const rightState = getState().explore.right;
dispatch(updateTimeRange({ exploreId: ExploreId.left, rawRange: rightState.range.raw }));
}
const isTimeSynced = getState().explore.syncedTimes;
dispatch(syncTimesAction({ syncedTimes: !isTimeSynced }));
dispatch(stateSave());
};
}
/**
* Reducer for an Explore area, to be used by the global Explore reducer.
*/
// Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated.
// ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice
// because the state would become frozen and during run time we would get errors because flot (Graph lib) would try to mutate
// the frozen state.
// https://github.com/reduxjs/redux-toolkit/issues/242
export const timeReducer = (state: ExploreItemState, action: AnyAction): ExploreItemState => {
if (changeRefreshIntervalAction.match(action)) {
const { refreshInterval } = action.payload;
const live = RefreshPicker.isLive(refreshInterval);
const sortOrder = refreshIntervalToSortOrder(refreshInterval);
const logsResult = sortLogsResult(state.logsResult, sortOrder);
if (RefreshPicker.isLive(state.refreshInterval) && !live) {
stopQueryState(state.querySubscription);
}
return {
...state,
refreshInterval,
queryResponse: {
...state.queryResponse,
state: live ? LoadingState.Streaming : LoadingState.Done,
},
isLive: live,
isPaused: live ? false : state.isPaused,
loading: live,
logsResult,
};
}
if (changeRangeAction.match(action)) {
const { range, absoluteRange } = action.payload;
return {
...state,
range,
absoluteRange,
update: makeInitialUpdateState(),
};
}
return state;
};

View File

@@ -0,0 +1,59 @@
import { EventBusExtended, DefaultTimeRange, LoadingState, LogsDedupStrategy, PanelData } from '@grafana/data';
import { ExploreItemState, ExploreUpdateState } from 'app/types/explore';
export const DEFAULT_RANGE = {
from: 'now-6h',
to: 'now',
};
export const makeInitialUpdateState = (): ExploreUpdateState => ({
datasource: false,
queries: false,
range: false,
mode: false,
});
/**
* Returns a fresh Explore area state
*/
export const makeExplorePaneState = (): ExploreItemState => ({
containerWidth: 0,
datasourceInstance: null,
requestedDatasourceName: null,
datasourceLoading: null,
datasourceMissing: false,
history: [],
queries: [],
initialized: false,
range: {
from: null,
to: null,
raw: DEFAULT_RANGE,
} as any,
absoluteRange: {
from: null,
to: null,
} as any,
scanning: false,
loading: false,
queryKeys: [],
urlState: null,
update: makeInitialUpdateState(),
latency: 0,
isLive: false,
isPaused: false,
urlReplaced: false,
queryResponse: createEmptyQueryResponse(),
tableResult: null,
graphResult: null,
logsResult: null,
dedupStrategy: LogsDedupStrategy.none,
eventBridge: (null as unknown) as EventBusExtended,
});
export const createEmptyQueryResponse = (): PanelData => ({
state: LoadingState.NotStarted,
series: [],
timeRange: DefaultTimeRange,
});

View File

@@ -1,10 +1,11 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { changeRefreshInterval, runQueries } from './state/actions';
import { setPausedStateAction } from './state/actionTypes';
import { RefreshPicker } from '@grafana/ui';
import { changeRefreshInterval } from './state/time';
import { setPausedStateAction } from './state/query';
import { ExploreId } from '../../types';
import { runQueries } from './state/query';
/**
* Hook that gives you all the functions needed to control the live tailing.

View File

@@ -1,7 +1,7 @@
import { splitOpen } from '../state/actions';
import { Field, LinkModel, TimeRange, mapInternalLinkToExplore } from '@grafana/data';
import { getLinkSrv } from '../../panel/panellinks/link_srv';
import { getDataSourceSrv, getTemplateSrv } from '@grafana/runtime';
import { splitOpen } from '../state/main';
/**
* Get links from the field of a dataframe and in addition check if there is associated

View File

@@ -0,0 +1,18 @@
import { isDateTime, RawTimeRange, TimeRange } from '@grafana/data';
export const toRawTimeRange = (range: TimeRange): RawTimeRange => {
let from = range.raw.from;
if (isDateTime(from)) {
from = from.valueOf().toString(10);
}
let to = range.raw.to;
if (isDateTime(to)) {
to = to.valueOf().toString(10);
}
return {
from,
to,
};
};

View File

@@ -1,107 +0,0 @@
import { ExploreId, ExploreItemState, ExploreState, RichHistoryQuery } from 'app/types/explore';
import { makeExploreItemState } from 'app/features/explore/state/reducers';
import { StoreState, UserState } from 'app/types';
import { TimeRange, dateTime, DataSourceApi } from '@grafana/data';
export const mockExploreState = (options: any = {}) => {
const isLive = options.isLive || false;
const history: any[] = [];
const eventBridge = {
emit: jest.fn(),
};
const streaming = options.streaming || undefined;
const datasourceInterval = options.datasourceInterval || '';
const refreshInterval = options.refreshInterval || '';
const containerWidth = options.containerWidth || 1980;
const queries = options.queries || [];
const datasourceError = options.datasourceError || null;
const scanner = options.scanner || jest.fn();
const scanning = options.scanning || false;
const datasourceId = options.datasourceId || '1337';
const exploreId = ExploreId.left;
const datasourceInstance: DataSourceApi<any> = options.datasourceInstance || {
id: 1337,
query: jest.fn(),
name: 'test',
testDatasource: jest.fn(),
meta: {
id: datasourceId,
streaming,
},
interval: datasourceInterval,
};
const range: TimeRange = options.range || {
from: dateTime('2019-01-01 10:00:00.000Z'),
to: dateTime('2019-01-01 16:00:00.000Z'),
raw: {
from: 'now-6h',
to: 'now',
},
};
const urlReplaced = options.urlReplaced || false;
const left: ExploreItemState = options.left || {
...makeExploreItemState(),
containerWidth,
datasourceError,
datasourceInstance,
eventBridge,
history,
isLive,
queries,
refreshInterval,
scanner,
scanning,
urlReplaced,
range,
};
const right: ExploreItemState = options.right || {
...makeExploreItemState(),
containerWidth,
datasourceError,
datasourceInstance,
eventBridge,
history,
isLive,
queries,
refreshInterval,
scanner,
scanning,
urlReplaced,
range,
};
const split: boolean = options.split || false;
const syncedTimes: boolean = options.syncedTimes || false;
const richHistory: RichHistoryQuery[] = [];
const explore: ExploreState = {
left,
right,
syncedTimes,
split,
richHistory,
};
const user: UserState = {
orgId: 1,
timeZone: 'browser',
};
const state: Partial<StoreState> = {
explore,
user,
};
return {
containerWidth,
datasourceId,
datasourceInstance,
datasourceInterval,
eventBridge,
exploreId,
history,
queries,
refreshInterval,
state,
scanner,
range,
};
};