mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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';
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
};
|
||||
};
|
||||
115
public/app/features/explore/state/datasource.test.ts
Normal file
115
public/app/features/explore/state/datasource.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
235
public/app/features/explore/state/datasource.ts
Normal file
235
public/app/features/explore/state/datasource.ts
Normal 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;
|
||||
};
|
||||
165
public/app/features/explore/state/explorePane.test.ts
Normal file
165
public/app/features/explore/state/explorePane.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
292
public/app/features/explore/state/explorePane.ts
Normal file
292
public/app/features/explore/state/explorePane.ts
Normal 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;
|
||||
};
|
||||
58
public/app/features/explore/state/history.ts
Normal file
58
public/app/features/explore/state/history.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
310
public/app/features/explore/state/main.ts
Normal file
310
public/app/features/explore/state/main.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
164
public/app/features/explore/state/query.test.ts
Normal file
164
public/app/features/explore/state/query.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
699
public/app/features/explore/state/query.ts
Normal file
699
public/app/features/explore/state/query.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
77
public/app/features/explore/state/time.test.ts
Normal file
77
public/app/features/explore/state/time.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
173
public/app/features/explore/state/time.ts
Normal file
173
public/app/features/explore/state/time.ts
Normal 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;
|
||||
};
|
||||
59
public/app/features/explore/state/utils.ts
Normal file
59
public/app/features/explore/state/utils.ts
Normal 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,
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
18
public/app/features/explore/utils/time.ts
Normal file
18
public/app/features/explore/utils/time.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user