diff --git a/packages/grafana-data/src/utils/url.ts b/packages/grafana-data/src/utils/url.ts index 3de4f91c106..7cf29ed8e7f 100644 --- a/packages/grafana-data/src/utils/url.ts +++ b/packages/grafana-data/src/utils/url.ts @@ -196,11 +196,15 @@ export const urlUtil = { }; /** - * Create an string that is used in URL to represent the Explore state. This is stringified json - * that is used as a state of a single Explore pane - it does not represent full Explore URL. + * Create an string that is used in URL to represent the Explore state. This is basically just a stringified json + * that is that used as a state of a single Explore pane so it does not represent full Explore URL. * * @param urlState + * @param compact this parameter is deprecated and will be removed in a future release. */ -export function serializeStateToUrlParam(urlState: ExploreUrlState): string { +export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string { + if (compact !== undefined) { + console.warn('`compact` parameter is deprecated and will be removed in a future release'); + } return JSON.stringify(urlState); } diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index 930b822a087..49bccf92632 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -48,6 +48,45 @@ describe('state functions', () => { }, }); }); + + it('returns a valid Explore state from a compact URL parameter', () => { + const paramValue = '["now-1h","now","Local",{"expr":"metric"},{"ui":[true,true,true,"none"]}]'; + expect(parseUrlState(paramValue)).toMatchObject({ + datasource: 'Local', + queries: [{ expr: 'metric' }], + range: { + from: 'now-1h', + to: 'now', + }, + }); + }); + + it('should not return a query for mode in the url', () => { + // Previous versions of Grafana included "Explore mode" in the URL; this should not be treated as a query. + const paramValue = + '["now-1h","now","x-ray-datasource",{"queryType":"getTraceSummaries"},{"mode":"Metrics"},{"ui":[true,true,true,"none"]}]'; + expect(parseUrlState(paramValue)).toMatchObject({ + datasource: 'x-ray-datasource', + queries: [{ queryType: 'getTraceSummaries' }], + range: { + from: 'now-1h', + to: 'now', + }, + }); + }); + + it('should return queries if queryType is present in the url', () => { + const paramValue = + '["now-1h","now","x-ray-datasource",{"queryType":"getTraceSummaries"},{"ui":[true,true,true,"none"]}]'; + expect(parseUrlState(paramValue)).toMatchObject({ + datasource: 'x-ray-datasource', + queries: [{ queryType: 'getTraceSummaries' }], + range: { + from: 'now-1h', + to: 'now', + }, + }); + }); }); describe('serializeStateToUrlParam', () => { diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 6d371537080..a28a58909bb 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -165,6 +165,16 @@ export function buildQueryTransaction( export const clearQueryKeys: (query: DataQuery) => DataQuery = ({ key, ...rest }) => rest; +const isSegment = (segment: { [key: string]: string }, ...props: string[]) => + props.some((prop) => segment.hasOwnProperty(prop)); + +enum ParseUrlStateIndex { + RangeFrom = 0, + RangeTo = 1, + Datasource = 2, + SegmentsStart = 3, +} + export const safeParseJson = (text?: string): any | undefined => { if (!text) { return; @@ -219,7 +229,25 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState { return errorResult; } - return parsed; + if (!Array.isArray(parsed)) { + return parsed; + } + + if (parsed.length <= ParseUrlStateIndex.SegmentsStart) { + console.error('Error parsing compact URL state for Explore.'); + return errorResult; + } + + const range = { + from: parsed[ParseUrlStateIndex.RangeFrom], + to: parsed[ParseUrlStateIndex.RangeTo], + }; + const datasource = parsed[ParseUrlStateIndex.Datasource]; + const parsedSegments = parsed.slice(ParseUrlStateIndex.SegmentsStart); + const queries = parsedSegments.filter((segment) => !isSegment(segment, 'ui', 'mode', '__panelsState')); + + const panelsState = parsedSegments.find((segment) => isSegment(segment, '__panelsState'))?.__panelsState; + return { datasource, queries, range, panelsState }; } export function generateKey(index = 0): string { diff --git a/public/app/features/explore/Wrapper.test.tsx b/public/app/features/explore/Wrapper.test.tsx index fe72fe6acdf..b80c6081306 100644 --- a/public/app/features/explore/Wrapper.test.tsx +++ b/public/app/features/explore/Wrapper.test.tsx @@ -12,40 +12,6 @@ import { splitOpen } from './state/main'; type Mock = jest.Mock; -type overrideParamsType = { - datasource?: string; - exprValue?: string; - rightDatasource?: string; - rightExprValue?: string; -}; - -const defaultUrlParams = ({ - datasource = 'loki', - exprValue = '{label="value"}', - rightDatasource, - rightExprValue, -}: overrideParamsType) => { - type urlParamsType = { left: string; right?: string }; - - const urlParams: urlParamsType = { - left: serializeStateToUrlParam({ - datasource: datasource, - queries: [{ refId: 'A', expr: exprValue }], - range: { from: 'now-1h', to: 'now' }, - }), - }; - - if (rightDatasource) { - urlParams.right = serializeStateToUrlParam({ - datasource: rightDatasource, - queries: [{ refId: 'A', expr: rightExprValue ? rightExprValue : exprValue }], - range: { from: 'now-1h', to: 'now' }, - }); - } - - return urlParams; -}; - jest.mock('app/core/core', () => { return { contextSrv: { @@ -96,7 +62,13 @@ describe('Wrapper', () => { }); it('runs query when url contains query and renders results', async () => { - const urlParams = defaultUrlParams({}); + const urlParams = { + left: serializeStateToUrlParam({ + datasource: 'loki', + queries: [{ refId: 'A', expr: '{ label="value"}' }], + range: { from: 'now-1h', to: 'now' }, + }), + }; const { datasources } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); @@ -107,7 +79,7 @@ describe('Wrapper', () => { await screen.findByText(/custom log line/i); // And that the editor gets the expr from the url - await screen.findByText(`loki Editor input: {label="value"}`); + await screen.findByText(`loki Editor input: { label="value"}`); // We did not change the url expect(locationService.getSearchObject()).toEqual({ @@ -118,12 +90,12 @@ describe('Wrapper', () => { // We called the data source query method once expect(datasources.loki.query).toBeCalledTimes(1); expect((datasources.loki.query as Mock).mock.calls[0][0]).toMatchObject({ - targets: [{ expr: '{label="value"}' }], + targets: [{ expr: '{ label="value"}' }], }); }); it('handles url change and runs the new query', async () => { - const urlParams = defaultUrlParams({}); + const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) }; const { datasources } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); // Wait for rendering the logs @@ -131,25 +103,29 @@ describe('Wrapper', () => { (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse('different log')); - locationService.partial(defaultUrlParams({ exprValue: '{label="different"}' })); + locationService.partial({ + left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="different"}' }]), + }); // Editor renders the new query - await screen.findByText(`loki Editor input: {label="different"}`); + await screen.findByText(`loki Editor input: { label="different"}`); // Renders new response await screen.findByText(/different log/i); }); it('handles url change and runs the new query with different datasource', async () => { - const urlParams = defaultUrlParams({}); + const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) }; const { datasources } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); // Wait for rendering the logs await screen.findByText(/custom log line/i); - await screen.findByText(`loki Editor input: {label="value"}`); + await screen.findByText(`loki Editor input: { label="value"}`); (datasources.elastic.query as Mock).mockReturnValueOnce(makeMetricsQueryResponse()); - locationService.partial(defaultUrlParams({ datasource: 'elastic', exprValue: 'other query' })); + locationService.partial({ + left: JSON.stringify(['now-1h', 'now', 'elastic', { expr: 'other query' }]), + }); // Editor renders the new query await screen.findByText(`elastic Editor input: other query`); @@ -158,7 +134,7 @@ describe('Wrapper', () => { }); it('handles changing the datasource manually', async () => { - const urlParams = defaultUrlParams({}); + const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}', refId: 'A' }]) }; const { datasources } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); await waitForExplore(); @@ -188,7 +164,18 @@ describe('Wrapper', () => { }); it('inits with two panes if specified in url', async () => { - const urlParams = defaultUrlParams({ rightDatasource: 'elastic', rightExprValue: 'error' }); + const urlParams = { + left: serializeStateToUrlParam({ + datasource: 'loki', + queries: [{ refId: 'A', expr: '{ label="value"}' }], + range: { from: 'now-1h', to: 'now' }, + }), + right: serializeStateToUrlParam({ + datasource: 'elastic', + queries: [{ refId: 'A', expr: 'error' }], + range: { from: 'now-1h', to: 'now' }, + }), + }; const { datasources } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); @@ -205,7 +192,7 @@ describe('Wrapper', () => { expect(logsLines.length).toBe(2); // And that the editor gets the expr from the url - await screen.findByText(`loki Editor input: {label="value"}`); + await screen.findByText(`loki Editor input: { label="value"}`); await screen.findByText(`elastic Editor input: error`); // We did not change the url @@ -217,7 +204,7 @@ describe('Wrapper', () => { // We called the data source query method once expect(datasources.loki.query).toBeCalledTimes(1); expect((datasources.loki.query as Mock).mock.calls[0][0]).toMatchObject({ - targets: [{ expr: '{label="value"}' }], + targets: [{ expr: '{ label="value"}' }], }); expect(datasources.elastic.query).toBeCalledTimes(1); @@ -227,11 +214,11 @@ describe('Wrapper', () => { }); it('can close a pane from a split', async () => { - const urlParams = defaultUrlParams({ rightDatasource: 'elastic' }); - const { datasources } = setupExplore({ urlParams }); - (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); - (datasources.elastic.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); - + const urlParams = { + left: JSON.stringify(['now-1h', 'now', 'loki', { refId: 'A' }]), + right: JSON.stringify(['now-1h', 'now', 'elastic', { refId: 'A' }]), + }; + setupExplore({ urlParams }); const closeButtons = await screen.findAllByTitle(/Close split pane/i); await userEvent.click(closeButtons[1]); @@ -242,55 +229,65 @@ describe('Wrapper', () => { }); it('handles url change to split view', async () => { - const urlParams = defaultUrlParams({}); - + const urlParams = { + left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), + }; const { datasources } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValue(makeLogsQueryResponse()); (datasources.elastic.query as Mock).mockReturnValue(makeLogsQueryResponse()); - locationService.partial(defaultUrlParams({ rightDatasource: 'elastic', rightExprValue: 'error' })); + locationService.partial({ + left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), + right: JSON.stringify(['now-1h', 'now', 'elastic', { expr: 'error' }]), + }); // Editor renders the new query - await screen.findByText(`loki Editor input: {label="value"}`); + await screen.findByText(`loki Editor input: { label="value"}`); await screen.findByText(`elastic Editor input: error`); }); it('handles opening split with split open func', async () => { - const urlParams = defaultUrlParams({}); + const urlParams = { + left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), + }; const { datasources, store } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValue(makeLogsQueryResponse()); (datasources.elastic.query as Mock).mockReturnValue(makeLogsQueryResponse()); // This is mainly to wait for render so that the left pane state is initialized as that is needed for splitOpen // to work - await screen.findByText(`loki Editor input: {label="value"}`); + await screen.findByText(`loki Editor input: { label="value"}`); store.dispatch(splitOpen({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any); // Editor renders the new query await screen.findByText(`elastic Editor input: error`); - await screen.findByText(`loki Editor input: {label="value"}`); + await screen.findByText(`loki Editor input: { label="value"}`); }); it('changes the document title of the explore page to include the datasource in use', async () => { - const urlParams = defaultUrlParams({}); + const urlParams = { + left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), + }; const { datasources } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValue(makeLogsQueryResponse()); // This is mainly to wait for render so that the left pane state is initialized as that is needed for the title // to include the datasource - await screen.findByText(`loki Editor input: {label="value"}`); + await screen.findByText(`loki Editor input: { label="value"}`); await waitFor(() => expect(document.title).toEqual('Explore - loki - Grafana')); }); it('changes the document title to include the two datasources in use in split view mode', async () => { - const urlParams = defaultUrlParams({}); + const urlParams = { + left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), + }; const { datasources, store } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValue(makeLogsQueryResponse()); (datasources.elastic.query as Mock).mockReturnValue(makeLogsQueryResponse()); // This is mainly to wait for render so that the left pane state is initialized as that is needed for splitOpen // to work - await screen.findByText(`loki Editor input: {label="value"}`); + await screen.findByText(`loki Editor input: { label="value"}`); store.dispatch(splitOpen({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any); await waitFor(() => expect(document.title).toEqual('Explore - loki | elastic - Grafana'));