mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
* Default synced to true, only show synced status if panes are split * Don’t default sync to true until split happens, handle syncing when init or history change sync happens * Sync times if origin pane and new pane have the same range
429 lines
17 KiB
TypeScript
429 lines
17 KiB
TypeScript
import { identity, isEmpty, isEqual, isObject, mapValues, omitBy } from 'lodash';
|
|
import { useEffect, useRef } from 'react';
|
|
|
|
import { CoreApp, ExploreUrlState, DataSourceApi, toURLRange, EventBusSrv } 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 { changeDatasource } from '../../state/datasource';
|
|
import { changePanelsStateAction, initializeExplore } from '../../state/explorePane';
|
|
import { clearPanes, splitClose, splitOpen, syncTimesAction } from '../../state/main';
|
|
import { runQueries, setQueriesAction } from '../../state/query';
|
|
import { selectPanes } from '../../state/selectors';
|
|
import { changeRangeAction, updateTime } from '../../state/time';
|
|
import { DEFAULT_RANGE, fromURLRange } from '../../state/utils';
|
|
import { withUniqueRefIds } from '../../utils/queries';
|
|
import { isFulfilled } from '../utils';
|
|
|
|
import { parseURL } from './parseURL';
|
|
|
|
/**
|
|
* Bi-directionally syncs URL changes with Explore's state.
|
|
*/
|
|
export function useStateSync(params: ExploreQueryParams) {
|
|
const { location } = useGrafana();
|
|
const dispatch = useDispatch();
|
|
const panesState = useSelector(selectPanes);
|
|
const orgId = useSelector((state) => state.user.orgId);
|
|
const prevParams = useRef(params);
|
|
const initState = useRef<'notstarted' | 'pending' | 'done'>('notstarted');
|
|
const { warning } = useAppNotification();
|
|
|
|
useEffect(() => {
|
|
// This happens when the user navigates to an explore "empty page" while within Explore.
|
|
// ie. by clicking on the explore when explore is active.
|
|
if (!params.panes) {
|
|
initState.current = 'notstarted';
|
|
prevParams.current = params;
|
|
}
|
|
}, [params]);
|
|
|
|
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
|
|
[
|
|
splitClose.type,
|
|
splitOpen.fulfilled.type,
|
|
runQueries.pending.type,
|
|
changeRangeAction.type,
|
|
changePanelsStateAction.type,
|
|
].includes(action.type),
|
|
effect: async (_, { cancelActiveListeners, delay, getState }) => {
|
|
// The following 2 lines will throttle 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);
|
|
}
|
|
},
|
|
})
|
|
);
|
|
|
|
// @ts-expect-error the return type of addListener is actually callable, but dispatch is not middleware-aware
|
|
return () => unsubscribe();
|
|
}, [dispatch, location]);
|
|
|
|
useEffect(() => {
|
|
const isURLOutOfSync = prevParams.current?.panes !== params.panes;
|
|
|
|
const [urlState, hasParseError] = parseURL(params);
|
|
hasParseError &&
|
|
warning(
|
|
'Could not parse Explore URL',
|
|
'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(([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(() => {
|
|
if (update.range) {
|
|
dispatch(updateTime({ exploreId, rawRange: fromURLRange(range) }));
|
|
}
|
|
|
|
if (update.queries) {
|
|
dispatch(setQueriesAction({ exploreId, queries: withUniqueRefIds(queries) }));
|
|
}
|
|
|
|
if (update.queries || update.range) {
|
|
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<DataQuery>
|
|
: (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<string, unknown> = {};
|
|
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';
|
|
});
|
|
}
|
|
|
|
prevParams.current = params;
|
|
|
|
isURLOutOfSync && initState.current === 'done' && sync();
|
|
}, [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 (_) {}
|
|
}
|
|
|
|
// TODO: if queries have multiple datasources we should return mixed datasource
|
|
// Else we try to find a datasource in the queries, returning the first one that exists
|
|
const queriesWithDS = queries.filter((q) => q.datasource);
|
|
for (const query of queriesWithDS) {
|
|
try {
|
|
return await getDatasourceSrv().get(query.datasource);
|
|
} catch (_) {}
|
|
}
|
|
|
|
// If none of the queries specify a avalid datasource, we use the last used one
|
|
const lastUsedDSUID = getLastUsedDatasourceUID(orgId);
|
|
|
|
return (
|
|
getDatasourceSrv()
|
|
.get(lastUsedDSUID)
|
|
// 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<typeof pruned>(pruned, isEmpty);
|
|
if (isEmpty(pruned)) {
|
|
return undefined;
|
|
}
|
|
return pruned;
|
|
}
|