Просмотр исходного кода

Merge pull request #13463 from grafana/davkal/explore-url-state

Explore: Store UI state in URL
David 7 лет назад
Родитель
Сommit
7b543ca4b5

+ 2 - 9
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': {

+ 3 - 3
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 }));
           }
         }
       });

+ 9 - 0
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 = /\[\]$/;

+ 1 - 1
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) {

+ 52 - 39
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<any, ExploreState> {
+export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
   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<any, ExploreState> {
       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<any, ExploreState> {
       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<any, ExploreState> {
         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<any, ExploreState> {
       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<any, ExploreState> {
   };
 
   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<any, ExploreState> {
     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<any, ExploreState> {
     if (showingLogs && supportsLogs) {
       this.runLogsQuery();
     }
+    this.saveState();
   };
 
   onQuerySuccess(datasourceId: string, queries: any[]): void {
@@ -471,6 +479,11 @@ export class Explore extends React.Component<any, ExploreState> {
     return datasource.metadataRequest(url);
   };
 
+  saveState = () => {
+    const { stateKey, onSaveState } = this.props;
+    onSaveState(stateKey, this.state);
+  };
+
   render() {
     const { datasourceSrv, position, split } = this.props;
     const {

+ 96 - 0
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);
+    });
+  });
+});

+ 95 - 15
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<any, any> {
-  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<WrapperProps, WrapperState> {
+  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 (
       <div className="explore-wrapper">
-        <Explore {...this.props} position="left" onChangeSplit={this.handleChangeSplit} split={split} />
-        {split ? (
+        <Explore
+          datasourceSrv={datasourceSrv}
+          onChangeSplit={this.onChangeSplit}
+          onSaveState={this.onSaveState}
+          position="left"
+          split={split}
+          stateKey={STATE_KEY_LEFT}
+          urlState={urlStateLeft}
+        />
+        {split && (
           <Explore
-            {...this.props}
-            initialState={initialState}
-            onChangeSplit={this.handleChangeSplit}
+            datasourceSrv={datasourceSrv}
+            onChangeSplit={this.onChangeSplit}
+            onSaveState={this.onSaveState}
             position="right"
             split={split}
+            splitState={splitState}
+            stateKey={STATE_KEY_RIGHT}
+            urlState={urlStateRight}
           />
-        ) : null}
+        )}
       </div>
     );
   }
 }
+
+const mapStateToProps = (state: StoreState) => ({
+  urlStates: state.location.query,
+});
+
+const mapDispatchToProps = {
+  updateLocation,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Wrapper));

+ 2 - 2
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: '' }];
 }

+ 3 - 3
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) {

+ 1 - 0
public/app/routes/routes.ts

@@ -116,6 +116,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
     })
     .when('/explore', {
       template: '<react-container />',
+      reloadOnSearch: false,
       resolve: {
         roles: () => ['Editor', 'Admin'],
         component: () => import(/* webpackChunkName: "explore" */ 'app/features/explore/Wrapper'),

+ 16 - 0
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;
+}