From 0c4e0d0f707ff0d9bdc02d8e1bc466519845498b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Thu, 14 Mar 2024 12:27:18 +0100 Subject: [PATCH] Explore: Reorganize useStateSync code (#84281) * Reorganize the code * Simplify syncToURL * Introduce InitState type --- .../hooks/useStateSync/external.utils.ts | 30 ++ .../explore/hooks/useStateSync/index.ts | 404 +----------------- .../hooks/useStateSync/internal.utils.ts | 146 +++++++ .../useStateSync/synchronizer/fromURL.ts | 88 ++++ .../hooks/useStateSync/synchronizer/init.ts | 127 ++++++ .../hooks/useStateSync/synchronizer/toURL.ts | 75 ++++ 6 files changed, 476 insertions(+), 394 deletions(-) create mode 100644 public/app/features/explore/hooks/useStateSync/external.utils.ts create mode 100644 public/app/features/explore/hooks/useStateSync/internal.utils.ts create mode 100644 public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts create mode 100644 public/app/features/explore/hooks/useStateSync/synchronizer/init.ts create mode 100644 public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts diff --git a/public/app/features/explore/hooks/useStateSync/external.utils.ts b/public/app/features/explore/hooks/useStateSync/external.utils.ts new file mode 100644 index 00000000000..e691ba13b33 --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/external.utils.ts @@ -0,0 +1,30 @@ +import { isEmpty, isObject, mapValues, omitBy } from 'lodash'; + +import { ExploreUrlState, toURLRange } from '@grafana/data'; +import { clearQueryKeys } from 'app/core/utils/explore'; +import { ExploreItemState } from 'app/types'; + +export function getUrlStateFromPaneState(pane: ExploreItemState): ExploreUrlState { + return { + // datasourceInstance should not be undefined anymore here but in case there is some path for it to be undefined + // lets just fallback instead of crashing. + datasource: pane.datasourceInstance?.uid || '', + queries: pane.queries.map(clearQueryKeys), + range: toURLRange(pane.range.raw), + // don't include panelsState in the url unless a piece of state is actually set + panelsState: pruneObject(pane.panelsState), + }; +} + +/** + * recursively walks an object, removing keys where the value is undefined + * if the resulting object is empty, returns undefined + **/ +function pruneObject(obj: object): object | undefined { + let pruned = mapValues(obj, (value) => (isObject(value) ? pruneObject(value) : value)); + pruned = omitBy(pruned, isEmpty); + if (isEmpty(pruned)) { + return undefined; + } + return pruned; +} diff --git a/public/app/features/explore/hooks/useStateSync/index.ts b/public/app/features/explore/hooks/useStateSync/index.ts index bb237aaa467..1056addadbf 100644 --- a/public/app/features/explore/hooks/useStateSync/index.ts +++ b/public/app/features/explore/hooks/useStateSync/index.ts @@ -1,26 +1,17 @@ -import { identity, isEmpty, isEqual, isObject, mapValues, omitBy } from 'lodash'; import { useEffect, useRef } from 'react'; -import { CoreApp, ExploreUrlState, DataSourceApi, toURLRange, EventBusSrv, isTruthy } from '@grafana/data'; -import { DataQuery, DataSourceRef } from '@grafana/schema'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { useAppNotification } from 'app/core/copy/appNotification'; -import { clearQueryKeys, getLastUsedDatasourceUID } from 'app/core/utils/explore'; -import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; -import { addListener, ExploreItemState, ExploreQueryParams, useDispatch, useSelector } from 'app/types'; +import { addListener, ExploreQueryParams, useDispatch, useSelector } from 'app/types'; -import { changeDatasource } from '../../state/datasource'; -import { changePanelsStateAction, initializeExplore } from '../../state/explorePane'; -import { clearPanes, splitClose, splitOpen, syncTimesAction } from '../../state/main'; -import { cancelQueries, runQueries, setQueriesAction } from '../../state/query'; import { selectPanes } from '../../state/selectors'; -import { changeRangeAction, updateTime } from '../../state/time'; -import { DEFAULT_RANGE, fromURLRange } from '../../state/utils'; -import { withUniqueRefIds } from '../../utils/queries'; -import { isFulfilled } from '../utils'; import { parseURL } from './parseURL'; +import { syncFromURL } from './synchronizer/fromURL'; +import { initializeFromURL } from './synchronizer/init'; +import { syncToURL, syncToURLPredicate } from './synchronizer/toURL'; + +export { getUrlStateFromPaneState } from './external.utils'; /** * Bi-directionally syncs URL changes with Explore's state. @@ -47,65 +38,13 @@ export function useStateSync(params: ExploreQueryParams) { useEffect(() => { const unsubscribe = dispatch( addListener({ - predicate: (action) => - /* - We want to update the URL when: - - a pane is opened or closed - - a query is run - - range is changed - - panel state is updated - - a datasource change has completed. - - Note: Changing datasource causes a bunch of actions to be dispatched, we want to update the URL - only when the change set has completed. This is done by checking if the changeDatasource.pending action - has been dispatched and pausing the listener until the changeDatasource.fulfilled action is dispatched. - */ - - { - paused.current = changeDatasource.pending.type === action.type; - - return ( - [ - splitClose.type, - splitOpen.fulfilled.type, - runQueries.pending.type, - changeRangeAction.type, - changePanelsStateAction.type, - changeDatasource.fulfilled.type, - ].includes(action.type) && !paused.current - ); - }, + predicate: (action) => syncToURLPredicate(paused, action), effect: async (_, { cancelActiveListeners, delay, getState }) => { // The following 2 lines will debounce updates to avoid creating history entries when rapid changes // are committed to the store. cancelActiveListeners(); await delay(200); - - const panesQueryParams = Object.entries(getState().explore.panes).reduce((acc, [id, paneState]) => { - if (!paneState) { - return acc; - } - return { - ...acc, - [id]: getUrlStateFromPaneState(paneState), - }; - }, {}); - - if (!isEqual(prevParams.current.panes, JSON.stringify(panesQueryParams))) { - // If there's no previous state it means we are mounting explore for the first time, - // in this case we want to replace the URL instead of pushing a new entry to the history. - // If the init state is 'pending' it means explore still hasn't finished initializing. in that case we skip - // pushing a new entry in the history as the first entry will be pushed after initialization. - const replace = - (!!prevParams.current.panes && Object.values(prevParams.current.panes).filter(Boolean).length === 0) || - initState.current === 'pending'; - - prevParams.current = { - panes: JSON.stringify(panesQueryParams), - }; - - location.partial({ panes: prevParams.current.panes }, replace); - } + syncToURL(getState().explore, prevParams, initState, location); }, }) ); @@ -124,341 +63,18 @@ export function useStateSync(params: ExploreQueryParams) { 'The requested URL contains invalid parameters, a default Explore state has been loaded.' ); - async function sync() { - // if navigating the history causes one of the time range to not being equal to all the other ones, - // we set syncedTimes to false to avoid inconsistent UI state. - // Ideally `syncedTimes` should be saved in the URL. - const paneArray = Object.values(urlState.panes); - if (paneArray.length > 1) { - const paneTimesUnequal = paneArray.some(({ range }, _, [{ range: firstRange }]) => !isEqual(range, firstRange)); - dispatch(syncTimesAction({ syncedTimes: !paneTimesUnequal })); // if all time ranges are equal, keep them synced - } - - Object.entries(urlState.panes).forEach(async ([exploreId, urlPane], i) => { - const { datasource, queries, range, panelsState } = urlPane; - - const paneState = panesState[exploreId]; - - if (paneState !== undefined) { - const update = urlDiff(urlPane, getUrlStateFromPaneState(paneState)); - - Promise.resolve() - .then(async () => { - if (update.datasource && datasource) { - await dispatch(changeDatasource({ exploreId, datasource })); - } - return; - }) - .then(async () => { - if (update.range) { - dispatch(updateTime({ exploreId, rawRange: fromURLRange(range) })); - } - - if (update.queries) { - dispatch(setQueriesAction({ exploreId, queries: withUniqueRefIds(queries) })); - } - - if (update.queries || update.range) { - await dispatch(cancelQueries(exploreId)); - dispatch(runQueries({ exploreId })); - } - - if (update.panelsState && panelsState) { - dispatch(changePanelsStateAction({ exploreId, panelsState })); - } - }); - } else { - // This happens when browser history is used to navigate. - // In this case we want to initialize the pane with the data from the URL - // if it's not present in the store. This may happen if the user has navigated - // from split view to non-split view and then back to split view. - dispatch( - initializeExplore({ - exploreId, - datasource: datasource || '', - queries: withUniqueRefIds(queries), - range: fromURLRange(range), - panelsState, - position: i, - eventBridge: new EventBusSrv(), - }) - ); - } - }); - - // Close all the panes that are not in the URL but are still in the store - // ie. because the user has navigated back after opening the split view. - Object.keys(panesState) - .filter((keyInStore) => !Object.keys(urlState.panes).includes(keyInStore)) - .forEach((paneId) => dispatch(splitClose(paneId))); - } - // This happens when the user first navigates to explore. // Here we want to initialize each pane initial data, wether it comes // from the url or as a result of migrations. if (!isURLOutOfSync && initState.current === 'notstarted') { initState.current = 'pending'; - - // Clear all the panes in the store first to avoid stale data. - dispatch(clearPanes()); - - Promise.all( - Object.entries(urlState.panes).map(([exploreId, { datasource, queries, range, panelsState }]) => { - return getPaneDatasource(datasource, queries, orgId).then((paneDatasource) => { - return Promise.resolve( - // Given the Grafana datasource will always be present, this should always be defined. - paneDatasource - ? queries.length - ? // if we have queries in the URL, we use them - withUniqueRefIds(queries) - // but filter out the ones that are not compatible with the pane datasource - .filter(getQueryFilter(paneDatasource)) - .map( - isMixedDatasource(paneDatasource) - ? identity - : (query) => ({ ...query, datasource: paneDatasource.getRef() }) - ) - : getDatasourceSrv() - // otherwise we get a default query from the pane datasource or from the default datasource if the pane datasource is mixed - .get(isMixedDatasource(paneDatasource) ? undefined : paneDatasource.getRef()) - .then((ds) => [getDefaultQuery(ds)]) - : [] - ).then(async (queries) => { - // we remove queries that have an invalid datasources - let validQueries = await removeQueriesWithInvalidDatasource(queries); - - if (!validQueries.length && paneDatasource) { - // and in case there's no query left we add a default one. - validQueries = [ - getDefaultQuery(isMixedDatasource(paneDatasource) ? await getDatasourceSrv().get() : paneDatasource), - ]; - } - - return { exploreId, range, panelsState, queries: validQueries, datasource: paneDatasource }; - }); - }); - }) - ).then(async (panes) => { - const initializedPanes = await Promise.all( - panes.map(({ exploreId, range, panelsState, queries, datasource }) => { - return dispatch( - initializeExplore({ - exploreId, - datasource, - queries, - range: fromURLRange(range), - panelsState, - eventBridge: new EventBusSrv(), - }) - ).unwrap(); - }) - ); - - if (initializedPanes.length > 1) { - const paneTimesUnequal = initializedPanes.some( - ({ state }, _, [{ state: firstState }]) => !isEqual(state.range.raw, firstState.range.raw) - ); - dispatch(syncTimesAction({ syncedTimes: !paneTimesUnequal })); // if all time ranges are equal, keep them synced - } - - const panesObj = initializedPanes.reduce((acc, { exploreId, state }) => { - return { - ...acc, - [exploreId]: getUrlStateFromPaneState(state), - }; - }, {}); - - // we need to use partial here beacuse replace doesn't encode the query params. - const oldQuery = location.getSearchObject(); - - // we create the default query params from the current URL, omitting all the properties we know should be in the final url. - // This includes params from previous schema versions and 'schemaVersion', 'panes', 'orgId' as we want to replace those. - let defaults: Record = {}; - for (const [key, value] of Object.entries(oldQuery).filter( - ([key]) => !['schemaVersion', 'panes', 'orgId', 'left', 'right'].includes(key) - )) { - defaults[key] = value; - } - - const searchParams = new URLSearchParams({ - // we set the schemaVersion as the first parameter so that when URLs are truncated the schemaVersion is more likely to be present. - schemaVersion: `${urlState.schemaVersion}`, - panes: JSON.stringify(panesObj), - orgId: `${orgId}`, - ...defaults, - }); - - location.replace({ - pathname: location.getLocation().pathname, - search: searchParams.toString(), - }); - initState.current = 'done'; - }); + initializeFromURL(urlState, initState, orgId, dispatch, location); } prevParams.current = params; if (isURLOutOfSync && initState.current === 'done') { - sync(); + syncFromURL(urlState, panesState, dispatch); } }, [dispatch, panesState, orgId, location, params, warning]); } - -function getDefaultQuery(ds: DataSourceApi) { - return { ...ds.getDefaultQuery?.(CoreApp.Explore), refId: 'A', datasource: ds.getRef() }; -} - -function isMixedDatasource(datasource: DataSourceApi) { - return datasource.name === MIXED_DATASOURCE_NAME; -} - -function getQueryFilter(datasource?: DataSourceApi) { - // if the root datasource is mixed, filter out queries that don't have a datasource. - if (datasource && isMixedDatasource(datasource)) { - return (q: DataQuery) => !!q.datasource; - } else { - // else filter out queries that have a datasource different from the root one. - // Queries may not have a datasource, if so, it's assumed they are using the root datasource - return (q: DataQuery) => { - if (!q.datasource) { - return true; - } - // Due to legacy URLs, `datasource` in queries may be a string. This logic should probably be in the migration - if (typeof q.datasource === 'string') { - return q.datasource === datasource?.uid; - } - - return q.datasource.uid === datasource?.uid; - }; - } -} - -async function removeQueriesWithInvalidDatasource(queries: DataQuery[]) { - const results = await Promise.allSettled( - queries.map((query) => { - return getDatasourceSrv() - .get(query.datasource) - .then((ds) => ({ - query, - ds, - })); - }) - ); - - return results.filter(isFulfilled).map(({ value }) => value.query); -} - -/** - * Returns the datasource that an explore pane should be using. - * If the URL specifies a datasource and that datasource exists, it will be used unless said datasource is mixed. - * Otherwise the datasource will be extracetd from the the first query specifying a valid datasource. - * - * If there's no datasource in the queries, the last used datasource will be used. - * if there's no last used datasource, the default datasource will be used. - * - * @param rootDatasource the top-level datasource specified in the URL - * @param queries the queries in the pane - * @param orgId the orgId of the user - * - * @returns the datasource UID that the pane should use, undefined if no suitable datasource is found - */ -async function getPaneDatasource( - rootDatasource: DataSourceRef | string | null | undefined, - queries: DataQuery[], - orgId: number -) { - // If there's a root datasource, use it unless it's unavailable - if (rootDatasource) { - try { - return await getDatasourceSrv().get(rootDatasource); - } catch (_) {} - } - - // Else we try to find a datasource in the queries - const queriesDatasources = [ - ...new Set( - queries - .map((q) => q.datasource) - .filter(isTruthy) - .map((ds) => (typeof ds === 'string' ? ds : ds.uid)) - ), - ]; - - try { - if (queriesDatasources.length >= 1) { - const datasources = (await Promise.allSettled(queriesDatasources.map((ds) => getDatasourceSrv().get(ds)))).filter( - isFulfilled - ); - - // if queries have multiple (valid) datasources, we return the mixed datasource - if (datasources.length > 1) { - return await getDatasourceSrv().get(MIXED_DATASOURCE_NAME); - } - - // otherwise we return the first datasource. - if (datasources.length === 1) { - return await getDatasourceSrv().get(queriesDatasources[0]); - } - } - } catch (_) {} - - // If none of the queries specify a valid datasource, we use the last used one - return ( - getDatasourceSrv() - .get(getLastUsedDatasourceUID(orgId)) - // Or the default one - .catch(() => getDatasourceSrv().get()) - .catch(() => undefined) - ); -} - -/** - * Compare 2 explore urls and return a map of what changed. Used to update the local state with all the - * side effects needed. - */ -const urlDiff = ( - oldUrlState: ExploreUrlState | undefined, - currentUrlState: ExploreUrlState | undefined -): { - datasource: boolean; - queries: boolean; - range: boolean; - panelsState: boolean; -} => { - const datasource = !isEqual(currentUrlState?.datasource, oldUrlState?.datasource); - const queries = !isEqual(currentUrlState?.queries, oldUrlState?.queries); - const range = !isEqual(currentUrlState?.range || DEFAULT_RANGE, oldUrlState?.range || DEFAULT_RANGE); - const panelsState = !isEqual(currentUrlState?.panelsState, oldUrlState?.panelsState); - - return { - datasource, - queries, - range, - panelsState, - }; -}; - -export function getUrlStateFromPaneState(pane: ExploreItemState): ExploreUrlState { - return { - // datasourceInstance should not be undefined anymore here but in case there is some path for it to be undefined - // lets just fallback instead of crashing. - datasource: pane.datasourceInstance?.uid || '', - queries: pane.queries.map(clearQueryKeys), - range: toURLRange(pane.range.raw), - // don't include panelsState in the url unless a piece of state is actually set - panelsState: pruneObject(pane.panelsState), - }; -} - -/** - * recursively walks an object, removing keys where the value is undefined - * if the resulting object is empty, returns undefined - **/ -function pruneObject(obj: object): object | undefined { - let pruned = mapValues(obj, (value) => (isObject(value) ? pruneObject(value) : value)); - pruned = omitBy(pruned, isEmpty); - if (isEmpty(pruned)) { - return undefined; - } - return pruned; -} diff --git a/public/app/features/explore/hooks/useStateSync/internal.utils.ts b/public/app/features/explore/hooks/useStateSync/internal.utils.ts new file mode 100644 index 00000000000..a600de5a03a --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/internal.utils.ts @@ -0,0 +1,146 @@ +import { isEqual } from 'lodash'; + +import { CoreApp, DataSourceApi, ExploreUrlState, isTruthy } from '@grafana/data'; +import { DataQuery, DataSourceRef } from '@grafana/schema'; +import { getLastUsedDatasourceUID } from 'app/core/utils/explore'; +import { DEFAULT_RANGE } from 'app/features/explore/state/utils'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; + +import { isFulfilled } from '../utils'; + +export type InitState = 'pending' | 'done' | 'notstarted'; + +/** + * Compare 2 explore urls and return a map of what changed. Used to update the local state with all the + * side effects needed. + */ +export const urlDiff = ( + oldUrlState: ExploreUrlState | undefined, + currentUrlState: ExploreUrlState | undefined +): { + datasource: boolean; + queries: boolean; + range: boolean; + panelsState: boolean; +} => { + const datasource = !isEqual(currentUrlState?.datasource, oldUrlState?.datasource); + const queries = !isEqual(currentUrlState?.queries, oldUrlState?.queries); + const range = !isEqual(currentUrlState?.range || DEFAULT_RANGE, oldUrlState?.range || DEFAULT_RANGE); + const panelsState = !isEqual(currentUrlState?.panelsState, oldUrlState?.panelsState); + + return { + datasource, + queries, + range, + panelsState, + }; +}; + +/** + * Returns the datasource that an explore pane should be using. + * If the URL specifies a datasource and that datasource exists, it will be used unless said datasource is mixed. + * Otherwise the datasource will be extracted from the the first query specifying a valid datasource. + * + * If there's no datasource in the queries, the last used datasource will be used. + * if there's no last used datasource, the default datasource will be used. + * + * @param rootDatasource the top-level datasource specified in the URL + * @param queries the queries in the pane + * @param orgId the orgId of the user + * + * @returns the datasource UID that the pane should use, undefined if no suitable datasource is found + */ +export async function getPaneDatasource( + rootDatasource: DataSourceRef | string | null | undefined, + queries: DataQuery[], + orgId: number +) { + // If there's a root datasource, use it unless it's unavailable + if (rootDatasource) { + try { + return await getDatasourceSrv().get(rootDatasource); + } catch (_) {} + } + + // Else we try to find a datasource in the queries + const queriesDatasources = [ + ...new Set( + queries + .map((q) => q.datasource) + .filter(isTruthy) + .map((ds) => (typeof ds === 'string' ? ds : ds.uid)) + ), + ]; + + try { + if (queriesDatasources.length >= 1) { + const datasources = (await Promise.allSettled(queriesDatasources.map((ds) => getDatasourceSrv().get(ds)))).filter( + isFulfilled + ); + + // if queries have multiple (valid) datasources, we return the mixed datasource + if (datasources.length > 1) { + return await getDatasourceSrv().get(MIXED_DATASOURCE_NAME); + } + + // otherwise we return the first datasource. + if (datasources.length === 1) { + return await getDatasourceSrv().get(queriesDatasources[0]); + } + } + } catch (_) {} + + // If none of the queries specify a valid datasource, we use the last used one + return ( + getDatasourceSrv() + .get(getLastUsedDatasourceUID(orgId)) + // Or the default one + .catch(() => getDatasourceSrv().get()) + .catch(() => undefined) + ); +} + +export function getDefaultQuery(ds: DataSourceApi) { + return { ...ds.getDefaultQuery?.(CoreApp.Explore), refId: 'A', datasource: ds.getRef() }; +} + +export function isMixedDatasource(datasource: DataSourceApi) { + return datasource.name === MIXED_DATASOURCE_NAME; +} + +export function getQueryFilter(datasource?: DataSourceApi) { + // if the root datasource is mixed, filter out queries that don't have a datasource. + if (datasource && isMixedDatasource(datasource)) { + return (q: DataQuery) => !!q.datasource; + } else { + // else filter out queries that have a datasource different from the root one. + // Queries may not have a datasource, if so, it's assumed they are using the root datasource + return (q: DataQuery) => { + if (!q.datasource) { + return true; + } + // Due to legacy URLs, `datasource` in queries may be a string. This logic should probably be in the migration + if (typeof q.datasource === 'string') { + return q.datasource === datasource?.uid; + } + + return q.datasource.uid === datasource?.uid; + }; + } +} + +export async function removeQueriesWithInvalidDatasource(queries: DataQuery[]) { + const results = await Promise.allSettled( + queries.map((query) => { + return getDatasourceSrv() + .get(query.datasource) + .then((ds) => ({ + query, + ds, + })); + }) + ); + + return results.filter(isFulfilled).map(({ value }) => value.query); +} diff --git a/public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts b/public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts new file mode 100644 index 00000000000..5497f481407 --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts @@ -0,0 +1,88 @@ +import { isEqual } from 'lodash'; + +import { EventBusSrv } from '@grafana/data'; +import { changeDatasource } from 'app/features/explore/state/datasource'; +import { changePanelsStateAction, initializeExplore } from 'app/features/explore/state/explorePane'; +import { splitClose, syncTimesAction } from 'app/features/explore/state/main'; +import { cancelQueries, runQueries, setQueriesAction } from 'app/features/explore/state/query'; +import { updateTime } from 'app/features/explore/state/time'; +import { fromURLRange } from 'app/features/explore/state/utils'; +import { withUniqueRefIds } from 'app/features/explore/utils/queries'; +import { ExploreItemState, ThunkDispatch } from 'app/types'; + +import { getUrlStateFromPaneState } from '../index'; +import { urlDiff } from '../internal.utils'; +import { ExploreURLV1 } from '../migrators/v1'; + +export function syncFromURL( + urlState: ExploreURLV1, + panesState: Record, + dispatch: ThunkDispatch +) { + // if navigating the history causes one of the time range to not being equal to all the other ones, + // we set syncedTimes to false to avoid inconsistent UI state. + // Ideally `syncedTimes` should be saved in the URL. + const paneArray = Object.values(urlState.panes); + if (paneArray.length > 1) { + const paneTimesUnequal = paneArray.some(({ range }, _, [{ range: firstRange }]) => !isEqual(range, firstRange)); + dispatch(syncTimesAction({ syncedTimes: !paneTimesUnequal })); // if all time ranges are equal, keep them synced + } + + Object.entries(urlState.panes).forEach(async ([exploreId, urlPane], i) => { + const { datasource, queries, range, panelsState } = urlPane; + + const paneState = panesState[exploreId]; + + if (paneState !== undefined) { + const update = urlDiff(urlPane, getUrlStateFromPaneState(paneState)); + + Promise.resolve() + .then(async () => { + if (update.datasource && datasource) { + await dispatch(changeDatasource({ exploreId, datasource })); + } + return; + }) + .then(async () => { + if (update.range) { + dispatch(updateTime({ exploreId, rawRange: fromURLRange(range) })); + } + + if (update.queries) { + dispatch(setQueriesAction({ exploreId, queries: withUniqueRefIds(queries) })); + } + + if (update.queries || update.range) { + await dispatch(cancelQueries(exploreId)); + dispatch(runQueries({ exploreId })); + } + + if (update.panelsState && panelsState) { + dispatch(changePanelsStateAction({ exploreId, panelsState })); + } + }); + } else { + // This happens when browser history is used to navigate. + // In this case we want to initialize the pane with the data from the URL + // if it's not present in the store. This may happen if the user has navigated + // from split view to non-split view and then back to split view. + dispatch( + initializeExplore({ + exploreId, + datasource: datasource || '', + queries: withUniqueRefIds(queries), + range: fromURLRange(range), + panelsState, + position: i, + eventBridge: new EventBusSrv(), + }) + ); + } + }); + + // Close all the panes that are not in the URL but are still in the store + // ie. because the user has navigated back after opening the split view. + Object.keys(panesState) + .filter((keyInStore) => !Object.keys(urlState.panes).includes(keyInStore)) + .forEach((paneId) => dispatch(splitClose(paneId))); +} diff --git a/public/app/features/explore/hooks/useStateSync/synchronizer/init.ts b/public/app/features/explore/hooks/useStateSync/synchronizer/init.ts new file mode 100644 index 00000000000..dae7cd105d3 --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/synchronizer/init.ts @@ -0,0 +1,127 @@ +import { identity, isEqual } from 'lodash'; +import { MutableRefObject } from 'react'; + +import { EventBusSrv } from '@grafana/data'; +import { LocationService } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; +import { initializeExplore } from 'app/features/explore/state/explorePane'; +import { clearPanes, syncTimesAction } from 'app/features/explore/state/main'; +import { fromURLRange } from 'app/features/explore/state/utils'; +import { withUniqueRefIds } from 'app/features/explore/utils/queries'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { ThunkDispatch } from 'app/types'; + +import { getUrlStateFromPaneState } from '../index'; +import { + getDefaultQuery, + getPaneDatasource, + getQueryFilter, + InitState, + isMixedDatasource, + removeQueriesWithInvalidDatasource, +} from '../internal.utils'; +import { ExploreURLV1 } from '../migrators/v1'; + +export function initializeFromURL( + urlState: ExploreURLV1, + initState: MutableRefObject, + orgId: number, + dispatch: ThunkDispatch, + location: LocationService +) { + // Clear all the panes in the store first to avoid stale data. + dispatch(clearPanes()); + + Promise.all( + Object.entries(urlState.panes).map(([exploreId, { datasource, queries, range, panelsState }]) => { + return getPaneDatasource(datasource, queries, orgId).then((paneDatasource) => { + return Promise.resolve( + // Given the Grafana datasource will always be present, this should always be defined. + paneDatasource + ? queries.length + ? // if we have queries in the URL, we use them + withUniqueRefIds(queries) + // but filter out the ones that are not compatible with the pane datasource + .filter(getQueryFilter(paneDatasource)) + .map( + isMixedDatasource(paneDatasource) + ? identity + : (query) => ({ ...query, datasource: paneDatasource.getRef() }) + ) + : getDatasourceSrv() + // otherwise we get a default query from the pane datasource or from the default datasource if the pane datasource is mixed + .get(isMixedDatasource(paneDatasource) ? undefined : paneDatasource.getRef()) + .then((ds) => [getDefaultQuery(ds)]) + : [] + ).then(async (queries) => { + // we remove queries that have an invalid datasources + let validQueries = await removeQueriesWithInvalidDatasource(queries); + + if (!validQueries.length && paneDatasource) { + // and in case there's no query left we add a default one. + validQueries = [ + getDefaultQuery(isMixedDatasource(paneDatasource) ? await getDatasourceSrv().get() : paneDatasource), + ]; + } + + return { exploreId, range, panelsState, queries: validQueries, datasource: paneDatasource }; + }); + }); + }) + ).then(async (panes) => { + const initializedPanes = await Promise.all( + panes.map(({ exploreId, range, panelsState, queries, datasource }) => { + return dispatch( + initializeExplore({ + exploreId, + datasource, + queries, + range: fromURLRange(range), + panelsState, + eventBridge: new EventBusSrv(), + }) + ).unwrap(); + }) + ); + + if (initializedPanes.length > 1) { + const paneTimesUnequal = initializedPanes.some( + ({ state }, _, [{ state: firstState }]) => !isEqual(state.range.raw, firstState.range.raw) + ); + dispatch(syncTimesAction({ syncedTimes: !paneTimesUnequal })); // if all time ranges are equal, keep them synced + } + + const panesObj = initializedPanes.reduce((acc, { exploreId, state }) => { + return { + ...acc, + [exploreId]: getUrlStateFromPaneState(state), + }; + }, {}); + + // we need to use partial here beacuse replace doesn't encode the query params. + const oldQuery = location.getSearchObject(); + + // we create the default query params from the current URL, omitting all the properties we know should be in the final url. + // This includes params from previous schema versions and 'schemaVersion', 'panes', 'orgId' as we want to replace those. + let defaults: Record = {}; + for (const [key, value] of Object.entries(oldQuery).filter( + ([key]) => !['schemaVersion', 'panes', 'orgId', 'left', 'right'].includes(key) + )) { + defaults[key] = value; + } + + const searchParams = new URLSearchParams({ + // we set the schemaVersion as the first parameter so that when URLs are truncated the schemaVersion is more likely to be present. + schemaVersion: `${urlState.schemaVersion}`, + panes: JSON.stringify(panesObj), + orgId: `${orgId}`, + ...defaults, + }); + + location.replace({ + pathname: location.getLocation().pathname, + search: searchParams.toString(), + }); + initState.current = 'done'; + }); +} diff --git a/public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts b/public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts new file mode 100644 index 00000000000..ba164419822 --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts @@ -0,0 +1,75 @@ +import { Action } from '@reduxjs/toolkit'; +import { isEqual } from 'lodash'; +import { MutableRefObject } from 'react'; + +import { UrlQueryMap } from '@grafana/data'; +import { LocationService } from '@grafana/runtime'; +import { changeDatasource } from 'app/features/explore/state/datasource'; +import { changePanelsStateAction } from 'app/features/explore/state/explorePane'; +import { splitClose, splitOpen } from 'app/features/explore/state/main'; +import { runQueries } from 'app/features/explore/state/query'; +import { changeRangeAction } from 'app/features/explore/state/time'; +import { ExploreState } from 'app/types'; + +import { getUrlStateFromPaneState } from '../index'; +import { InitState } from '../internal.utils'; + +/* +We want to update the URL when: + - a pane is opened or closed + - a query is run + - range is changed + - panel state is updated + - a datasource change has completed. + +Note: Changing datasource causes a bunch of actions to be dispatched, we want to update the URL +only when the change set has completed. This is done by checking if the changeDatasource.pending action +has been dispatched and pausing the listener until the changeDatasource.fulfilled action is dispatched. +*/ +export function syncToURLPredicate(paused: MutableRefObject, action: Action) { + paused.current = changeDatasource.pending.type === action.type; + + return ( + [ + splitClose.type, + splitOpen.fulfilled.type, + runQueries.pending.type, + changeRangeAction.type, + changePanelsStateAction.type, + changeDatasource.fulfilled.type, + ].includes(action.type) && !paused.current + ); +} + +export function syncToURL( + exploreState: ExploreState, + prevParams: MutableRefObject, + initState: MutableRefObject, + location: LocationService +) { + const panesQueryParams = Object.entries(exploreState.panes).reduce((acc, [id, paneState]) => { + if (!paneState) { + return acc; + } + return { + ...acc, + [id]: getUrlStateFromPaneState(paneState), + }; + }, {}); + + if (!isEqual(prevParams.current.panes, JSON.stringify(panesQueryParams))) { + // If there's no previous state it means we are mounting explore for the first time, + // in this case we want to replace the URL instead of pushing a new entry to the history. + // If the init state is 'pending' it means explore still hasn't finished initializing. in that case we skip + // pushing a new entry in the history as the first entry will be pushed after initialization. + const replace = + (!!prevParams.current.panes && Object.values(prevParams.current.panes).filter(Boolean).length === 0) || + initState.current === 'pending'; + + prevParams.current = { + panes: JSON.stringify(panesQueryParams), + }; + + location.partial({ panes: prevParams.current.panes }, replace); + } +}