From 5bed54170e25637ef85c21b96c825231e1268620 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Mon, 9 Nov 2020 14:48:24 +0100 Subject: [PATCH] Chore: Split Explore redux code into multiple sections (#28819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * Add section comments * Rename ExploreItem to ExplorePane * Fix imports Co-authored-by: Zoltán Bedi --- public/app/core/reducers/root.ts | 2 +- .../dashboard/utils/getPanelMenu.test.ts | 2 +- .../features/dashboard/utils/getPanelMenu.ts | 2 +- public/app/features/explore/Explore.test.tsx | 2 +- public/app/features/explore/Explore.tsx | 17 +- .../app/features/explore/ExploreToolbar.tsx | 14 +- public/app/features/explore/LogsContainer.tsx | 7 +- public/app/features/explore/QueryRow.tsx | 5 +- .../explore/RichHistory/RichHistoryCard.tsx | 7 +- .../RichHistory/RichHistoryContainer.tsx | 4 +- .../app/features/explore/TableContainer.tsx | 2 +- public/app/features/explore/Wrapper.tsx | 2 +- .../app/features/explore/state/actionTypes.ts | 313 ------- .../features/explore/state/actions.test.ts | 374 -------- public/app/features/explore/state/actions.ts | 856 ------------------ .../features/explore/state/datasource.test.ts | 115 +++ .../app/features/explore/state/datasource.ts | 235 +++++ .../explore/state/explorePane.test.ts | 165 ++++ .../app/features/explore/state/explorePane.ts | 292 ++++++ public/app/features/explore/state/history.ts | 58 ++ .../state/{reducers.test.ts => main.test.ts} | 414 +++------ public/app/features/explore/state/main.ts | 310 +++++++ .../app/features/explore/state/query.test.ts | 164 ++++ public/app/features/explore/state/query.ts | 699 ++++++++++++++ public/app/features/explore/state/reducers.ts | 649 ------------- .../app/features/explore/state/time.test.ts | 77 ++ public/app/features/explore/state/time.ts | 173 ++++ public/app/features/explore/state/utils.ts | 59 ++ .../features/explore/useLiveTailControls.ts | 7 +- public/app/features/explore/utils/links.ts | 2 +- public/app/features/explore/utils/time.ts | 18 + public/test/mocks/mockExploreState.ts | 107 --- 32 files changed, 2537 insertions(+), 2616 deletions(-) delete mode 100644 public/app/features/explore/state/actionTypes.ts delete mode 100644 public/app/features/explore/state/actions.test.ts delete mode 100644 public/app/features/explore/state/actions.ts create mode 100644 public/app/features/explore/state/datasource.test.ts create mode 100644 public/app/features/explore/state/datasource.ts create mode 100644 public/app/features/explore/state/explorePane.test.ts create mode 100644 public/app/features/explore/state/explorePane.ts create mode 100644 public/app/features/explore/state/history.ts rename public/app/features/explore/state/{reducers.test.ts => main.test.ts} (55%) create mode 100644 public/app/features/explore/state/main.ts create mode 100644 public/app/features/explore/state/query.test.ts create mode 100644 public/app/features/explore/state/query.ts delete mode 100644 public/app/features/explore/state/reducers.ts create mode 100644 public/app/features/explore/state/time.test.ts create mode 100644 public/app/features/explore/state/time.ts create mode 100644 public/app/features/explore/state/utils.ts create mode 100644 public/app/features/explore/utils/time.ts delete mode 100644 public/test/mocks/mockExploreState.ts diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 503583e01bf..49def914a36 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -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'; diff --git a/public/app/features/dashboard/utils/getPanelMenu.test.ts b/public/app/features/dashboard/utils/getPanelMenu.test.ts index 8e8b318b8c0..7ff92deba02 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.test.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.test.ts @@ -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: { diff --git a/public/app/features/dashboard/utils/getPanelMenu.ts b/public/app/features/dashboard/utils/getPanelMenu.ts index 3f97c8dabea..3c8a8282ebc 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.ts @@ -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'; diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 14fe431e9aa..0467c7e7cf5 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -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'; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 506766b78ee..eea83ec378c 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -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'; diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index bad04f9a3ef..05f7f8ecd56 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -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; diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index abcb42d631f..56397a1955d 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -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'; diff --git a/public/app/features/explore/QueryRow.tsx b/public/app/features/explore/QueryRow.tsx index e27c506e953..e5aefff47c7 100644 --- a/public/app/features/explore/QueryRow.tsx +++ b/public/app/features/explore/QueryRow.tsx @@ -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; diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.tsx index 663eb46f4cd..7e1e888d4cd 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.tsx @@ -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; diff --git a/public/app/features/explore/RichHistory/RichHistoryContainer.tsx b/public/app/features/explore/RichHistory/RichHistoryContainer.tsx index c4b2f241012..70eb290118d 100644 --- a/public/app/features/explore/RichHistory/RichHistoryContainer.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryContainer.tsx @@ -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 { diff --git a/public/app/features/explore/TableContainer.tsx b/public/app/features/explore/TableContainer.tsx index f81da0a70ad..297a45a67b8 100644 --- a/public/app/features/explore/TableContainer.tsx +++ b/public/app/features/explore/TableContainer.tsx @@ -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'; diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx index 9a3679d4509..f54e73149e2 100644 --- a/public/app/features/explore/Wrapper.tsx +++ b/public/app/features/explore/Wrapper.tsx @@ -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 { diff --git a/public/app/features/explore/state/actionTypes.ts b/public/app/features/explore/state/actionTypes.ts deleted file mode 100644 index 8670f1405f8..00000000000 --- a/public/app/features/explore/state/actionTypes.ts +++ /dev/null @@ -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('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('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('explore/changeSize'); - -/** - * Change the time range of Explore. Usually called from the Timepicker or a graph interaction. - */ -export const changeRefreshIntervalAction = createAction('explore/changeRefreshInterval'); - -/** - * Change deduplication strategy for logs. - */ -export const changeDedupStrategyAction = createAction('explore/changeDedupStrategyAction'); - -/** - * Clear all queries and results. - */ -export const clearQueriesAction = createAction('explore/clearQueries'); - -/** - * Cancel running queries. - */ -export const cancelQueriesAction = createAction('explore/cancelQueries'); - -/** - * Highlight expressions in the log results - */ -export const highlightLogsExpressionAction = createAction( - '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('explore/initializeExplore'); - -/** - * Display an error when no datasources have been configured - */ -export const loadDatasourceMissingAction = createAction('explore/loadDatasourceMissing'); - -/** - * Start the async process of loading a datasource to display a loading indicator - */ -export const loadDatasourcePendingAction = createAction('explore/loadDatasourcePending'); - -/** - * Datasource loading was completed. - */ -export const loadDatasourceReadyAction = createAction('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('explore/modifyQueries'); - -export const queryStreamUpdatedAction = createAction('explore/queryStreamUpdated'); - -export const queryStoreSubscriptionAction = createAction( - 'explore/queryStoreSubscription' -); - -/** - * Remove query row of the given index, as well as associated query results. - */ -export const removeQueryRowAction = createAction('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('explore/scanStart'); - -/** - * Stop any scanning for more results. - */ -export const scanStopAction = createAction('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('explore/setQueries'); - -/** - * Close the split view and save URL state. - */ -export const splitCloseAction = createAction('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('explore/splitOpen'); - -export const syncTimesAction = createAction('explore/syncTimes'); - -export const richHistoryUpdatedAction = createAction('explore/richHistoryUpdated'); - -/** - * Updates datasource instance before datasouce loading has started - */ -export const updateDatasourceInstanceAction = createAction( - 'explore/updateDatasourceInstance' -); - -export const toggleLogLevelAction = createAction('explore/toggleLogLevel'); - -/** - * Resets state for explore. - */ -export const resetExploreAction = createAction('explore/resetExplore'); -export const queriesImportedAction = createAction('explore/queriesImported'); - -export const historyUpdatedAction = createAction('explore/historyUpdated'); - -export const setUrlReplacedAction = createAction('explore/setUrlReplaced'); - -export const changeRangeAction = createAction('explore/changeRange'); - -export const changeLoadingStateAction = createAction('changeLoadingState'); - -export const setPausedStateAction = createAction('explore/setPausedState'); diff --git a/public/app/features/explore/state/actions.test.ts b/public/app/features/explore/state/actions.test.ts deleted file mode 100644 index 909f329706d..00000000000 --- a/public/app/features/explore/state/actions.test.ts +++ /dev/null @@ -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; - -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) => { - 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; - 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 = { - 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); - }); - }); - }); -}); diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts deleted file mode 100644 index 87656c6a8d4..00000000000 --- a/public/app/features/explore/state/actions.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - return changeSizeAction({ exploreId, height, width }); -} - -export const updateTimeRange = (options: { - exploreId: ExploreId; - rawRange?: RawTimeRange; - absoluteRange?: AbsoluteTimeRange; -}): ThunkResult => { - 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 { - return changeRefreshIntervalAction({ exploreId, refreshInterval }); -} - -/** - * Change logs deduplication strategy. - */ -export const changeDedupStrategy = ( - exploreId: ExploreId, - dedupStrategy: LogsDedupStrategy -): PayloadAction => { - return changeDedupStrategyAction({ exploreId, dedupStrategy }); -}; - -/** - * Clear all queries and results. - */ -export function clearQueries(exploreId: ExploreId): ThunkResult { - return dispatch => { - dispatch(scanStopAction({ exploreId })); - dispatch(clearQueriesAction({ exploreId })); - dispatch(stateSave()); - }; -} - -/** - * Cancel running queries - */ -export function cancelQueries(exploreId: ExploreId): ThunkResult { - 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 { - 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 { - 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 => { - 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 => { - 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 => { - 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 { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 { - 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 { - 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 { - 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(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 { - 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 { - 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 { - 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; - openInNewWindow?: (url: string) => void; -} - -export const navigateToExplore = ( - panel: PanelModel, - dependencies: NavigateToExploreDependencies -): ThunkResult => { - 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 })); - }; -}; diff --git a/public/app/features/explore/state/datasource.test.ts b/public/app/features/explore/state/datasource.test.ts new file mode 100644 index 00000000000..93070fda05b --- /dev/null +++ b/public/app/features/explore/state/datasource.test.ts @@ -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() + .givenReducer(datasourceReducer, initialState) + .whenActionIsDispatched(updateDatasourceInstanceAction({ exploreId: ExploreId.left, datasourceInstance })) + .thenStateShouldEqual(expectedState); + }); + }); + }); + }); +}); diff --git a/public/app/features/explore/state/datasource.ts b/public/app/features/explore/state/datasource.ts new file mode 100644 index 00000000000..e93842a4a89 --- /dev/null +++ b/public/app/features/explore/state/datasource.ts @@ -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('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('explore/loadDatasourcePending'); + +/** + * Datasource loading was completed. + */ +export interface LoadDatasourceReadyPayload { + exploreId: ExploreId; + history: HistoryItem[]; +} +export const loadDatasourceReadyAction = createAction('explore/loadDatasourceReady'); + +/** + * Updates datasource instance before datasource loading has started + */ +export interface UpdateDatasourceInstancePayload { + exploreId: ExploreId; + datasourceInstance: DataSourceApi; +} +export const updateDatasourceInstanceAction = createAction( + 'explore/updateDatasourceInstance' +); + +// +// Action creators +// + +/** + * Loads a new datasource identified by the given name. + */ +export function changeDatasource( + exploreId: ExploreId, + datasourceName: string, + options?: { importQueries: boolean } +): ThunkResult { + 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 { + 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 => { + 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 => { + 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; +}; diff --git a/public/app/features/explore/state/explorePane.test.ts b/public/app/features/explore/state/explorePane.test.ts new file mode 100644 index 00000000000..d015eb097d4 --- /dev/null +++ b/public/app/features/explore/state/explorePane.test.ts @@ -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; + +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) => { + 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; + 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() + .givenReducer(paneReducer, initialState) + .whenActionIsDispatched( + changeDedupStrategyAction({ exploreId: ExploreId.left, dedupStrategy: LogsDedupStrategy.exact }) + ) + .thenStateShouldEqual({ + ...makeExplorePaneState(), + dedupStrategy: LogsDedupStrategy.exact, + }); + }); + }); + }); +}); diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts new file mode 100644 index 00000000000..27d01446fae --- /dev/null +++ b/public/app/features/explore/state/explorePane.ts @@ -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('explore/changeSize'); + +/** + * Change deduplication strategy for logs. + */ +export interface ChangeDedupStrategyPayload { + exploreId: ExploreId; + dedupStrategy: LogsDedupStrategy; +} +export const changeDedupStrategyAction = createAction('explore/changeDedupStrategyAction'); + +/** + * Highlight expressions in the log results + */ +export interface HighlightLogsExpressionPayload { + exploreId: ExploreId; + expressions: string[]; +} +export const highlightLogsExpressionAction = createAction( + '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('explore/initializeExplore'); + +export interface ToggleLogLevelPayload { + exploreId: ExploreId; + hiddenLogLevels: LogLevel[]; +} +export const toggleLogLevelAction = createAction('explore/toggleLogLevel'); + +export interface SetUrlReplacedPayload { + exploreId: ExploreId; +} +export const setUrlReplacedAction = createAction('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 { + return changeSizeAction({ exploreId, height, width }); +} + +/** + * Change logs deduplication strategy. + */ +export const changeDedupStrategy = ( + exploreId: ExploreId, + dedupStrategy: LogsDedupStrategy +): PayloadAction => { + 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 { + 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 => { + 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 { + 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; +}; diff --git a/public/app/features/explore/state/history.ts b/public/app/features/explore/state/history.ts new file mode 100644 index 00000000000..a9ad2eff161 --- /dev/null +++ b/public/app/features/explore/state/history.ts @@ -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('explore/historyUpdated'); + +// +// Action creators +// + +export const updateRichHistory = (ts: number, property: string, updatedProperty?: string): ThunkResult => { + 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 => { + 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; +}; diff --git a/public/app/features/explore/state/reducers.test.ts b/public/app/features/explore/state/main.test.ts similarity index 55% rename from public/app/features/explore/state/reducers.test.ts rename to public/app/features/explore/state/main.test.ts index af8b80bb551..85f4d2dab72 100644 --- a/public/app/features/explore/state/reducers.test.ts +++ b/public/app/features/explore/state/main.test.ts @@ -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 = { + 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() - .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() - .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() - .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() - .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() - .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() - .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() - .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() - .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() - .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() - .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() @@ -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, + }; +}; diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts new file mode 100644 index 00000000000..4ee5c1281ac --- /dev/null +++ b/public/app/features/explore/state/main.ts @@ -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('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('explore/splitOpen'); + +export interface SyncTimesPayload { + syncedTimes: boolean; +} +export const syncTimesAction = createAction('explore/syncTimes'); + +export const richHistoryUpdatedAction = createAction('explore/richHistoryUpdated'); + +/** + * Resets state for explore. + */ +export interface ResetExplorePayload { + force?: boolean; +} +export const resetExploreAction = createAction('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(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 { + 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 { + return dispatch => { + dispatch(splitCloseAction({ itemId })); + dispatch(stateSave()); + }; +} + +export interface NavigateToExploreDependencies { + getDataSourceSrv: () => DataSourceSrv; + getTimeSrv: () => TimeSrv; + getExploreUrl: (args: GetExploreUrlArguments) => Promise; + openInNewWindow?: (url: string) => void; +} + +export const navigateToExplore = ( + panel: PanelModel, + dependencies: NavigateToExploreDependencies +): ThunkResult => { + 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, + 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, + }, + }; +}; diff --git a/public/app/features/explore/state/query.test.ts b/public/app/features/explore/state/query.test.ts new file mode 100644 index 00000000000..5bf6f5875fb --- /dev/null +++ b/public/app/features/explore/state/query.test.ts @@ -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() + .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() + .givenReducer(queryReducer, initialState) + .whenActionIsDispatched(scanStopAction({ exploreId: ExploreId.left })) + .thenStateShouldEqual({ + ...makeExplorePaneState(), + scanning: false, + scanRange: undefined, + }); + }); + }); + + describe('query rows', () => { + it('adds a new query row', () => { + reducerTester() + .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() + .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() + .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; + }); + }); + }); +}); diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts new file mode 100644 index 00000000000..e975b72fc87 --- /dev/null +++ b/public/app/features/explore/state/query.ts @@ -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('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('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('explore/changeQuery'); + +/** + * Clear all queries and results. + */ +export interface ClearQueriesPayload { + exploreId: ExploreId; +} +export const clearQueriesAction = createAction('explore/clearQueries'); + +/** + * Cancel running queries. + */ +export const cancelQueriesAction = createAction('explore/cancelQueries'); + +export interface QueriesImportedPayload { + exploreId: ExploreId; + queries: DataQuery[]; +} +export const queriesImportedAction = createAction('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('explore/modifyQueries'); + +export interface QueryStoreSubscriptionPayload { + exploreId: ExploreId; + querySubscription: Unsubscribable; +} +export const queryStoreSubscriptionAction = createAction( + 'explore/queryStoreSubscription' +); + +export interface QueryEndedPayload { + exploreId: ExploreId; + response: ExplorePanelData; +} +export const queryStreamUpdatedAction = createAction('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('explore/setQueries'); + +export interface ChangeLoadingStatePayload { + exploreId: ExploreId; + loadingState: LoadingState; +} +export const changeLoadingStateAction = createAction('changeLoadingState'); + +export interface SetPausedStatePayload { + exploreId: ExploreId; + isPaused: boolean; +} +export const setPausedStateAction = createAction('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('explore/scanStart'); + +/** + * Stop any scanning for more results. + */ +export interface ScanStopPayload { + exploreId: ExploreId; +} +export const scanStopAction = createAction('explore/scanStop'); + +// +// Action creators +// + +/** + * Adds a query row after the row with the given index. + */ +export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult { + 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 { + 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 { + return dispatch => { + dispatch(scanStopAction({ exploreId })); + dispatch(clearQueriesAction({ exploreId })); + dispatch(stateSave()); + }; +} + +/** + * Cancel running queries + */ +export function cancelQueries(exploreId: ExploreId): ThunkResult { + 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 => { + 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 { + 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 => { + 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 { + 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 { + 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 +): 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, + }; +}; diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts deleted file mode 100644 index 194f2cbd7cd..00000000000 --- a/public/app/features/explore/state/reducers.ts +++ /dev/null @@ -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 -): 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, - 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, -}; diff --git a/public/app/features/explore/state/time.test.ts b/public/app/features/explore/state/time.test.ts new file mode 100644 index 00000000000..b281248f789 --- /dev/null +++ b/public/app/features/explore/state/time.test.ts @@ -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() + .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() + .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() + .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); + }); + }); + }); +}); diff --git a/public/app/features/explore/state/time.ts b/public/app/features/explore/state/time.ts new file mode 100644 index 00000000000..000a103630b --- /dev/null +++ b/public/app/features/explore/state/time.ts @@ -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('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('explore/changeRefreshInterval'); + +export const updateTimeRange = (options: { + exploreId: ExploreId; + rawRange?: RawTimeRange; + absoluteRange?: AbsoluteTimeRange; +}): ThunkResult => { + 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 { + return changeRefreshIntervalAction({ exploreId, refreshInterval }); +} + +export const updateTime = (config: { + exploreId: ExploreId; + rawRange?: RawTimeRange; + absoluteRange?: AbsoluteTimeRange; +}): ThunkResult => { + 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 { + 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; +}; diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts new file mode 100644 index 00000000000..995d2e01665 --- /dev/null +++ b/public/app/features/explore/state/utils.ts @@ -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, +}); diff --git a/public/app/features/explore/useLiveTailControls.ts b/public/app/features/explore/useLiveTailControls.ts index dad5ab92ec8..a13780661d3 100644 --- a/public/app/features/explore/useLiveTailControls.ts +++ b/public/app/features/explore/useLiveTailControls.ts @@ -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. diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index a28a3eed1cb..eb601f4b550 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -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 diff --git a/public/app/features/explore/utils/time.ts b/public/app/features/explore/utils/time.ts new file mode 100644 index 00000000000..f2726ae1414 --- /dev/null +++ b/public/app/features/explore/utils/time.ts @@ -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, + }; +}; diff --git a/public/test/mocks/mockExploreState.ts b/public/test/mocks/mockExploreState.ts deleted file mode 100644 index c632827a189..00000000000 --- a/public/test/mocks/mockExploreState.ts +++ /dev/null @@ -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 = 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 = { - explore, - user, - }; - - return { - containerWidth, - datasourceId, - datasourceInstance, - datasourceInterval, - eventBridge, - exploreId, - history, - queries, - refreshInterval, - state, - scanner, - range, - }; -};