diff --git a/docs/sources/explore/_index.md b/docs/sources/explore/_index.md index 48dc9e9bd91..88e3fa51a99 100644 --- a/docs/sources/explore/_index.md +++ b/docs/sources/explore/_index.md @@ -97,8 +97,8 @@ where: // ... any other datasource-specific query parameters }[]; // array of queries for this pane range: { - from: string | number; // the start time, in milliseconds since epoch - to: string | number; // the end time, in milliseconds since epoch + from: string; // the start time, in milliseconds since epoch + to: string; // the end time, in milliseconds since epoch } } ``` diff --git a/public/app/features/explore/hooks/useStateSync/index.test.tsx b/public/app/features/explore/hooks/useStateSync/index.test.tsx index dee9c6044f5..1a9daf675c1 100644 --- a/public/app/features/explore/hooks/useStateSync/index.test.tsx +++ b/public/app/features/explore/hooks/useStateSync/index.test.tsx @@ -396,6 +396,40 @@ describe('useStateSync', () => { expect(store.getState().explore.panes['one']?.range.raw).toMatchObject({ from: 'now-6h', to: 'now' }); }); + it('Changes time range when the range in the URL is updated to absolute range', async () => { + const { waitForNextUpdate, rerender, store } = setup({ + queryParams: { + panes: JSON.stringify({ + one: { datasource: 'loki-uid', queries: [{ expr: 'a' }], range: { from: 'now-1h', to: 'now' } }, + }), + schemaVersion: 1, + }, + }); + + await waitForNextUpdate(); + + expect(store.getState().explore.panes['one']?.range.raw).toMatchObject({ from: 'now-1h', to: 'now' }); + + rerender({ + children: null, + params: { + panes: JSON.stringify({ + one: { + datasource: 'loki-uid', + queries: [{ expr: 'a' }], + range: { from: '1500000000000', to: '1500000001000' }, + }, + }), + schemaVersion: 1, + }, + }); + + await waitForNextUpdate(); + + expect(store.getState().explore.panes['one']?.range.raw.from.valueOf().toString()).toEqual('1500000000000'); + expect(store.getState().explore.panes['one']?.range.raw.to.valueOf().toString()).toEqual('1500000001000'); + }); + it('uses the first query datasource if no root datasource is specified in the URL', async () => { const { waitForNextUpdate, store } = setup({ queryParams: { diff --git a/public/app/features/explore/hooks/useStateSync/index.ts b/public/app/features/explore/hooks/useStateSync/index.ts index f6515b87d90..b06fbea83a1 100644 --- a/public/app/features/explore/hooks/useStateSync/index.ts +++ b/public/app/features/explore/hooks/useStateSync/index.ts @@ -1,7 +1,7 @@ import { identity, isEmpty, isEqual, isObject, mapValues, omitBy } from 'lodash'; import { useEffect, useRef } from 'react'; -import { CoreApp, ExploreUrlState, isDateTime, TimeRange, RawTimeRange, DataSourceApi } from '@grafana/data'; +import { CoreApp, ExploreUrlState, RawTimeRange, DataSourceApi } from '@grafana/data'; import { DataQuery, DataSourceRef } from '@grafana/schema'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { clearQueryKeys, getLastUsedDatasourceUID } from 'app/core/utils/explore'; @@ -15,7 +15,7 @@ import { clearPanes, splitClose, splitOpen, syncTimesAction } from '../../state/ import { runQueries, setQueriesAction } from '../../state/query'; import { selectPanes } from '../../state/selectors'; import { changeRangeAction, updateTime } from '../../state/time'; -import { DEFAULT_RANGE } from '../../state/utils'; +import { DEFAULT_RANGE, fromURLRange, toURLTimeRange } from '../../state/utils'; import { withUniqueRefIds } from '../../utils/queries'; import { isFulfilled } from '../utils'; @@ -118,7 +118,7 @@ export function useStateSync(params: ExploreQueryParams) { }) .then(() => { if (update.range) { - dispatch(updateTime({ exploreId, rawRange: range })); + dispatch(updateTime({ exploreId, rawRange: fromURLRange(range) })); } if (update.queries) { @@ -358,7 +358,7 @@ const urlDiff = ( } => { 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 range = !areRangesEqual(currentUrlState?.range || DEFAULT_RANGE, oldUrlState?.range || DEFAULT_RANGE); const panelsState = !isEqual(currentUrlState?.panelsState, oldUrlState?.panelsState); return { @@ -369,13 +369,20 @@ const urlDiff = ( }; }; +const areRangesEqual = (a: RawTimeRange, b: RawTimeRange): boolean => { + const parsedA = toURLTimeRange(a); + const parsedB = toURLTimeRange(b); + + return parsedA.from === parsedB.from && parsedA.to === parsedB.to; +}; + 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: toRawTimeRange(pane.range), + range: toURLTimeRange(pane.range.raw), // don't include panelsState in the url unless a piece of state is actually set panelsState: pruneObject(pane.panelsState), }; @@ -393,20 +400,3 @@ function pruneObject(obj: object): object | undefined { } return pruned; } - -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/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts index fb486fed953..6ba81adbe9e 100644 --- a/public/app/features/explore/state/utils.ts +++ b/public/app/features/explore/state/utils.ts @@ -157,6 +157,41 @@ export function getRange(range: RawTimeRange, timeZone: TimeZone): TimeRange { }; } +/** + * @param range RawTimeRange - Note: Range in the URL is not RawTimeRange compliant (see #72578 for more details) + */ +export function fromURLRange(range: RawTimeRange): RawTimeRange { + let rawTimeRange: RawTimeRange = DEFAULT_RANGE; + let parsedRange = { + from: parseRawTime(range.from), + to: parseRawTime(range.to), + }; + if (parsedRange.from !== null && parsedRange.to !== null) { + rawTimeRange = { from: parsedRange.from, to: parsedRange.to }; + } + return rawTimeRange; +} + +/** + * @param range RawTimeRange - Note: Range in the URL is not RawTimeRange compliant (see #72578 for more details) + */ +export const toURLTimeRange = (range: RawTimeRange): RawTimeRange => { + let from = range.from; + if (isDateTime(from)) { + from = from.valueOf().toString(); + } + + let to = range.to; + if (isDateTime(to)) { + to = to.valueOf().toString(); + } + + return { + from, + to, + }; +}; + function parseRawTime(value: string | DateTime): TimeFragment | null { if (value === null) { return null;