diff --git a/public/app/core/reducers/location.ts b/public/app/core/reducers/location.ts index 6a356c4ea5a..2089cfe9f59 100644 --- a/public/app/core/reducers/location.ts +++ b/public/app/core/reducers/location.ts @@ -1,6 +1,6 @@ import { Action } from 'app/core/actions/location'; -import { LocationState, UrlQueryMap } from 'app/types'; -import { toUrlParams } from 'app/core/utils/url'; +import { LocationState } from 'app/types'; +import { renderUrl } from 'app/core/utils/url'; export const initialState: LocationState = { url: '', @@ -9,13 +9,6 @@ export const initialState: LocationState = { routeParams: {}, }; -function renderUrl(path: string, query: UrlQueryMap | undefined): string { - if (query && Object.keys(query).length > 0) { - path += '?' + toUrlParams(query); - } - return path; -} - export const locationReducer = (state = initialState, action: Action): LocationState => { switch (action.type) { case 'UPDATE_LOCATION': { diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index d05e9b0c21c..a0c7cdec3cb 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -4,7 +4,7 @@ import _ from 'lodash'; import config from 'app/core/config'; import coreModule from 'app/core/core_module'; import appEvents from 'app/core/app_events'; -import { encodePathComponent } from 'app/core/utils/location_util'; +import { renderUrl } from 'app/core/utils/url'; import Mousetrap from 'mousetrap'; import 'mousetrap-global-bind'; @@ -200,8 +200,8 @@ export class KeybindingSrv { ...datasource.getExploreState(panel), range, }; - const exploreState = encodePathComponent(JSON.stringify(state)); - this.$location.url(`/explore?state=${exploreState}`); + const exploreState = JSON.stringify(state); + this.$location.url(renderUrl('/explore', { state: exploreState })); } } }); diff --git a/public/app/core/utils/url.ts b/public/app/core/utils/url.ts index 198029b0e9f..ab8be8ad222 100644 --- a/public/app/core/utils/url.ts +++ b/public/app/core/utils/url.ts @@ -2,6 +2,15 @@ * @preserve jquery-param (c) 2015 KNOWLEDGECODE | MIT */ +import { UrlQueryMap } from 'app/types'; + +export function renderUrl(path: string, query: UrlQueryMap | undefined): string { + if (query && Object.keys(query).length > 0) { + path += '?' + toUrlParams(query); + } + return path; +} + export function toUrlParams(a) { const s = []; const rbracket = /\[\]$/; diff --git a/public/app/features/dashboard/time_srv.ts b/public/app/features/dashboard/time_srv.ts index 5bf23c66bab..a96bc89daa7 100644 --- a/public/app/features/dashboard/time_srv.ts +++ b/public/app/features/dashboard/time_srv.ts @@ -113,7 +113,7 @@ export class TimeSrv { } private timeHasChangedSinceLoad() { - return this.timeAtLoad.from !== this.time.from || this.timeAtLoad.to !== this.time.to; + return this.timeAtLoad && (this.timeAtLoad.from !== this.time.from || this.timeAtLoad.to !== this.time.to); } setAutoRefresh(interval) { diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 22c48a65080..66e1fc0ff6b 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { hot } from 'react-hot-loader'; import Select from 'react-select'; +import { Query, Range, ExploreUrlState } from 'app/types/explore'; import kbn from 'app/core/utils/kbn'; import colors from 'app/core/utils/colors'; import store from 'app/core/store'; import TimeSeries from 'app/core/time_series2'; -import { decodePathComponent } from 'app/core/utils/location_util'; import { parse as parseDate } from 'app/core/utils/datemath'; import ElapsedTime from './ElapsedTime'; @@ -47,37 +47,32 @@ function makeTimeSeriesList(dataList, options) { }); } -function parseUrlState(initial: string | undefined) { - if (initial) { - try { - const parsed = JSON.parse(decodePathComponent(initial)); - return { - datasource: parsed.datasource, - queries: parsed.queries.map(q => q.query), - range: parsed.range, - }; - } catch (e) { - console.error(e); - } - } - return { datasource: null, queries: [], range: DEFAULT_RANGE }; +interface ExploreProps { + datasourceSrv: any; + onChangeSplit: (split: boolean, state?: ExploreState) => void; + onSaveState: (key: string, state: ExploreState) => void; + position: string; + split: boolean; + splitState?: ExploreState; + stateKey: string; + urlState: ExploreUrlState; } -interface ExploreState { +export interface ExploreState { datasource: any; datasourceError: any; datasourceLoading: boolean | null; datasourceMissing: boolean; + datasourceName?: string; graphResult: any; history: any[]; - initialDatasource?: string; latency: number; loading: any; logsResult: any; - queries: any[]; + queries: Query[]; queryErrors: any[]; queryHints: any[]; - range: any; + range: Range; requestOptions: any; showingGraph: boolean; showingLogs: boolean; @@ -88,20 +83,21 @@ interface ExploreState { tableResult: any; } -export class Explore extends React.Component { +export class Explore extends React.PureComponent { el: any; constructor(props) { super(props); - const initialState: ExploreState = props.initialState; - const { datasource, queries, range } = parseUrlState(props.routeParams.state); + // Split state overrides everything + const splitState: ExploreState = props.splitState; + const { datasource, queries, range } = props.urlState; this.state = { datasource: null, datasourceError: null, datasourceLoading: null, datasourceMissing: false, + datasourceName: datasource, graphResult: null, - initialDatasource: datasource, history: [], latency: 0, loading: false, @@ -118,13 +114,13 @@ export class Explore extends React.Component { supportsLogs: null, supportsTable: null, tableResult: null, - ...initialState, + ...splitState, }; } async componentDidMount() { const { datasourceSrv } = this.props; - const { initialDatasource } = this.state; + const { datasourceName } = this.state; if (!datasourceSrv) { throw new Error('No datasource service passed as props.'); } @@ -133,15 +129,15 @@ export class Explore extends React.Component { this.setState({ datasourceLoading: true }); // Priority: datasource in url, default datasource, first explore datasource let datasource; - if (initialDatasource) { - datasource = await datasourceSrv.get(initialDatasource); + if (datasourceName) { + datasource = await datasourceSrv.get(datasourceName); } else { datasource = await datasourceSrv.get(); } if (!datasource.meta.explore) { datasource = await datasourceSrv.get(datasources[0].name); } - this.setDatasource(datasource); + await this.setDatasource(datasource); } else { this.setState({ datasourceMissing: true }); } @@ -188,9 +184,14 @@ export class Explore extends React.Component { supportsLogs, supportsTable, datasourceLoading: false, + datasourceName: datasource.name, queries: nextQueries, }, - () => datasourceError === null && this.onSubmit() + () => { + if (datasourceError === null) { + this.onSubmit(); + } + } ); } @@ -220,7 +221,8 @@ export class Explore extends React.Component { queryHints: [], tableResult: null, }); - const datasource = await this.props.datasourceSrv.get(option.value); + const datasourceName = option.value; + const datasource = await this.props.datasourceSrv.get(datasourceName); this.setDatasource(datasource); }; @@ -259,21 +261,25 @@ export class Explore extends React.Component { }; onClickClear = () => { - this.setState({ - graphResult: null, - logsResult: null, - latency: 0, - queries: ensureQueries(), - queryErrors: [], - queryHints: [], - tableResult: null, - }); + this.setState( + { + graphResult: null, + logsResult: null, + latency: 0, + queries: ensureQueries(), + queryErrors: [], + queryHints: [], + tableResult: null, + }, + this.saveState + ); }; onClickCloseSplit = () => { const { onChangeSplit } = this.props; if (onChangeSplit) { onChangeSplit(false); + this.saveState(); } }; @@ -291,6 +297,7 @@ export class Explore extends React.Component { state.queries = state.queries.map(({ edited, ...rest }) => rest); if (onChangeSplit) { onChangeSplit(true, state); + this.saveState(); } }; @@ -349,6 +356,7 @@ export class Explore extends React.Component { if (showingLogs && supportsLogs) { this.runLogsQuery(); } + this.saveState(); }; onQuerySuccess(datasourceId: string, queries: any[]): void { @@ -471,6 +479,11 @@ export class Explore extends React.Component { return datasource.metadataRequest(url); }; + saveState = () => { + const { stateKey, onSaveState } = this.props; + onSaveState(stateKey, this.state); + }; + render() { const { datasourceSrv, position, split } = this.props; const { diff --git a/public/app/features/explore/Wrapper.test.tsx b/public/app/features/explore/Wrapper.test.tsx new file mode 100644 index 00000000000..c71d3d384dc --- /dev/null +++ b/public/app/features/explore/Wrapper.test.tsx @@ -0,0 +1,96 @@ +import { serializeStateToUrlParam, parseUrlState } from './Wrapper'; +import { DEFAULT_RANGE } from './TimePicker'; +import { ExploreState } from './Explore'; + +const DEFAULT_EXPLORE_STATE: ExploreState = { + datasource: null, + datasourceError: null, + datasourceLoading: null, + datasourceMissing: false, + datasourceName: '', + graphResult: null, + history: [], + latency: 0, + loading: false, + logsResult: null, + queries: [], + queryErrors: [], + queryHints: [], + range: DEFAULT_RANGE, + requestOptions: null, + showingGraph: true, + showingLogs: true, + showingTable: true, + supportsGraph: null, + supportsLogs: null, + supportsTable: null, + tableResult: null, +}; + +describe('Wrapper state functions', () => { + describe('parseUrlState', () => { + it('returns default state on empty string', () => { + expect(parseUrlState('')).toMatchObject({ + datasource: null, + queries: [], + range: DEFAULT_RANGE, + }); + }); + }); + describe('serializeStateToUrlParam', () => { + it('returns url parameter value for a state object', () => { + const state = { + ...DEFAULT_EXPLORE_STATE, + datasourceName: 'foo', + range: { + from: 'now - 5h', + to: 'now', + }, + queries: [ + { + query: 'metric{test="a/b"}', + }, + { + query: 'super{foo="x/z"}', + }, + ], + }; + expect(serializeStateToUrlParam(state)).toBe( + '{"datasource":"foo","queries":[{"query":"metric{test=\\"a/b\\"}"},' + + '{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now - 5h","to":"now"}}' + ); + }); + }); + describe('interplay', () => { + it('can parse the serialized state into the original state', () => { + const state = { + ...DEFAULT_EXPLORE_STATE, + datasourceName: 'foo', + range: { + from: 'now - 5h', + to: 'now', + }, + queries: [ + { + query: 'metric{test="a/b"}', + }, + { + query: 'super{foo="x/z"}', + }, + ], + }; + const serialized = serializeStateToUrlParam(state); + const parsed = parseUrlState(serialized); + + // Account for datasource vs datasourceName + const { datasource, ...rest } = parsed; + const sameState = { + ...rest, + datasource: DEFAULT_EXPLORE_STATE.datasource, + datasourceName: datasource, + }; + + expect(state).toMatchObject(sameState); + }); + }); +}); diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx index 6bdbd7cc42f..5a1b3f2831c 100644 --- a/public/app/features/explore/Wrapper.tsx +++ b/public/app/features/explore/Wrapper.tsx @@ -1,33 +1,113 @@ -import React, { PureComponent } from 'react'; +import React, { Component } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; -import Explore from './Explore'; +import { updateLocation } from 'app/core/actions'; +import { StoreState } from 'app/types'; +import { ExploreUrlState } from 'app/types/explore'; -export default class Wrapper extends PureComponent { - state = { - initialState: null, - split: false, +import Explore, { ExploreState } from './Explore'; +import { DEFAULT_RANGE } from './TimePicker'; + +export function parseUrlState(initial: string | undefined): ExploreUrlState { + if (initial) { + try { + return JSON.parse(decodeURI(initial)); + } catch (e) { + console.error(e); + } + } + return { datasource: null, queries: [], range: DEFAULT_RANGE }; +} + +export function serializeStateToUrlParam(state: ExploreState): string { + const urlState: ExploreUrlState = { + datasource: state.datasourceName, + queries: state.queries.map(q => ({ query: q.query })), + range: state.range, + }; + return JSON.stringify(urlState); +} + +interface WrapperProps { + backendSrv?: any; + datasourceSrv?: any; + updateLocation: typeof updateLocation; + urlStates: { [key: string]: string }; +} + +interface WrapperState { + split: boolean; + splitState: ExploreState; +} + +const STATE_KEY_LEFT = 'state'; +const STATE_KEY_RIGHT = 'stateRight'; + +export class Wrapper extends Component { + urlStates: { [key: string]: string }; + + constructor(props: WrapperProps) { + super(props); + this.urlStates = props.urlStates; + this.state = { + split: Boolean(props.urlStates[STATE_KEY_RIGHT]), + splitState: undefined, + }; + } + + onChangeSplit = (split: boolean, splitState: ExploreState) => { + this.setState({ split, splitState }); }; - handleChangeSplit = (split, initialState) => { - this.setState({ split, initialState }); + onSaveState = (key: string, state: ExploreState) => { + const urlState = serializeStateToUrlParam(state); + this.urlStates[key] = urlState; + this.props.updateLocation({ + query: this.urlStates, + }); }; render() { + const { datasourceSrv } = this.props; // State overrides for props from first Explore - const { initialState, split } = this.state; + const { split, splitState } = this.state; + const urlStateLeft = parseUrlState(this.urlStates[STATE_KEY_LEFT]); + const urlStateRight = parseUrlState(this.urlStates[STATE_KEY_RIGHT]); return (
- - {split ? ( + + {split && ( - ) : null} + )}
); } } + +const mapStateToProps = (state: StoreState) => ({ + urlStates: state.location.query, +}); + +const mapDispatchToProps = { + updateLocation, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Wrapper)); diff --git a/public/app/features/explore/utils/query.ts b/public/app/features/explore/utils/query.ts index d774f619a30..4766b85f040 100644 --- a/public/app/features/explore/utils/query.ts +++ b/public/app/features/explore/utils/query.ts @@ -3,8 +3,8 @@ export function generateQueryKey(index = 0) { } export function ensureQueries(queries?) { - if (queries && typeof queries === 'object' && queries.length > 0 && typeof queries[0] === 'string') { - return queries.map((query, i) => ({ key: generateQueryKey(i), query })); + if (queries && typeof queries === 'object' && queries.length > 0 && typeof queries[0].query === 'string') { + return queries.map(({ query }, i) => ({ key: generateQueryKey(i), query })); } return [{ key: generateQueryKey(), query: '' }]; } diff --git a/public/app/features/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts index 5eecf6036d8..c74c0716cc8 100644 --- a/public/app/features/panel/metrics_panel_ctrl.ts +++ b/public/app/features/panel/metrics_panel_ctrl.ts @@ -6,7 +6,7 @@ import kbn from 'app/core/utils/kbn'; import { PanelCtrl } from 'app/features/panel/panel_ctrl'; import * as rangeUtil from 'app/core/utils/rangeutil'; import * as dateMath from 'app/core/utils/datemath'; -import { encodePathComponent } from 'app/core/utils/location_util'; +import { renderUrl } from 'app/core/utils/url'; import { metricsTabDirective } from './metrics_tab'; @@ -331,8 +331,8 @@ class MetricsPanelCtrl extends PanelCtrl { ...this.datasource.getExploreState(this.panel), range, }; - const exploreState = encodePathComponent(JSON.stringify(state)); - this.$location.url(`/explore?state=${exploreState}`); + const exploreState = JSON.stringify(state); + this.$location.url(renderUrl('/explore', { state: exploreState })); } addQuery(target) { diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index dbaa1c02952..5c627bce542 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -116,6 +116,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { }) .when('/explore', { template: '', + reloadOnSearch: false, resolve: { roles: () => ['Editor', 'Admin'], component: () => import(/* webpackChunkName: "explore" */ 'app/features/explore/Wrapper'), diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts new file mode 100644 index 00000000000..64d65e35f3c --- /dev/null +++ b/public/app/types/explore.ts @@ -0,0 +1,16 @@ +export interface Range { + from: string; + to: string; +} + +export interface Query { + query: string; + edited?: boolean; + key?: string; +} + +export interface ExploreUrlState { + datasource: string; + queries: Query[]; + range: Range; +}