소스 검색

Explore: Adds ability to save a panel's query from Explore (#17982)

* Explore: Adds ability to return to origin dashboard
kay delaney 6 년 전
부모
커밋
a838d2b30a

+ 1 - 1
packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx

@@ -61,7 +61,7 @@ export class RefreshPicker extends PureComponent<Props> {
       <div className={cssClasses}>
         <div className="refresh-picker-buttons">
           <Tooltip placement="top" content={tooltip}>
-            <button className="btn btn--radius-right-0 navbar-button navbar-button--refresh" onClick={onRefresh}>
+            <button className="btn btn--radius-right-0 navbar-button navbar-button--border-right-0" onClick={onRefresh}>
               <i className="fa fa-refresh" />
             </button>
           </Tooltip>

+ 1 - 1
packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss

@@ -6,7 +6,7 @@
     display: flex;
   }
 
-  .navbar-button--refresh {
+  .navbar-button--border-right-0 {
     border-right: 0;
   }
 

+ 4 - 1
packages/grafana-ui/src/components/Select/ButtonSelect.tsx

@@ -43,6 +43,7 @@ export interface Props<T> {
   onOpenMenu?: () => void;
   onCloseMenu?: () => void;
   tabSelectsValue?: boolean;
+  autoFocus?: boolean;
 }
 
 export class ButtonSelect<T> extends PureComponent<Props<T>> {
@@ -65,14 +66,16 @@ export class ButtonSelect<T> extends PureComponent<Props<T>> {
       onOpenMenu,
       onCloseMenu,
       tabSelectsValue,
+      autoFocus = true,
     } = this.props;
     const combinedComponents = {
       ...components,
       Control: ButtonComponent({ label, className, iconClass }),
     };
+
     return (
       <Select
-        autoFocus
+        autoFocus={autoFocus}
         backspaceRemovesValue={false}
         isClearable={false}
         isSearchable={false}

+ 7 - 6
public/app/core/services/backend_srv.ts

@@ -263,14 +263,15 @@ export class BackendSrv implements BackendService {
     return this.get(`/api/folders/${uid}`);
   }
 
-  saveDashboard(dash: DashboardModel, options: any) {
-    options = options || {};
-
+  saveDashboard(
+    dash: DashboardModel,
+    { message = '', folderId, overwrite = false }: { message?: string; folderId?: number; overwrite?: boolean } = {}
+  ) {
     return this.post('/api/dashboards/db/', {
       dashboard: dash,
-      folderId: options.folderId,
-      overwrite: options.overwrite === true,
-      message: options.message || '',
+      folderId,
+      overwrite,
+      message,
     });
   }
 

+ 1 - 2
public/app/core/services/keybindingSrv.ts

@@ -186,7 +186,6 @@ export class KeybindingSrv {
       if (dashboard.meta.focusPanelId) {
         appEvents.emit('panel-change-view', {
           fullscreen: true,
-          edit: null,
           panelId: dashboard.meta.focusPanelId,
           toggle: true,
         });
@@ -199,7 +198,7 @@ export class KeybindingSrv {
         if (dashboard.meta.focusPanelId) {
           const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
           const datasource = await this.datasourceSrv.get(panel.datasource);
-          const url = await getExploreUrl(panel.targets, datasource, this.datasourceSrv, this.timeSrv);
+          const url = await getExploreUrl(panel, panel.targets, datasource, this.datasourceSrv, this.timeSrv);
           if (url) {
             this.$timeout(() => this.$location.url(url));
           }

+ 1 - 0
public/app/core/utils/explore.test.ts

@@ -30,6 +30,7 @@ const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
     showingLogs: true,
     dedupStrategy: LogsDedupStrategy.none,
   },
+  originPanelId: undefined,
 };
 
 describe('state functions', () => {

+ 13 - 4
public/app/core/utils/explore.ts

@@ -18,7 +18,7 @@ import { renderUrl } from 'app/core/utils/url';
 import store from 'app/core/store';
 import { getNextRefIdChar } from './query';
 // Types
-import { DataQuery, DataSourceApi, DataQueryError, DataQueryRequest } from '@grafana/ui';
+import { DataQuery, DataSourceApi, DataQueryError, DataQueryRequest, PanelModel } from '@grafana/ui';
 import {
   ExploreUrlState,
   HistoryItem,
@@ -29,6 +29,7 @@ import {
 } from 'app/types/explore';
 import { config } from '../config';
 import { PanelQueryState } from '../../features/dashboard/state/PanelQueryState';
+import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
 
 export const DEFAULT_RANGE = {
   from: 'now-1h',
@@ -55,7 +56,13 @@ export const lastUsedDatasourceKeyForOrgId = (orgId: number) => `${LAST_USED_DAT
  * @param datasourceSrv Datasource service to query other datasources in case the panel datasource is mixed
  * @param timeSrv Time service to get the current dashboard range from
  */
-export async function getExploreUrl(panelTargets: any[], panelDatasource: any, datasourceSrv: any, timeSrv: any) {
+export async function getExploreUrl(
+  panel: PanelModel,
+  panelTargets: DataQuery[],
+  panelDatasource: any,
+  datasourceSrv: any,
+  timeSrv: TimeSrv
+) {
   let exploreDatasource = panelDatasource;
   let exploreTargets: DataQuery[] = panelTargets;
   let url: string;
@@ -86,7 +93,7 @@ export async function getExploreUrl(panelTargets: any[], panelDatasource: any, d
       };
     }
 
-    const exploreState = JSON.stringify(state);
+    const exploreState = JSON.stringify({ ...state, originPanelId: panel.id });
     url = renderUrl('/explore', { left: exploreState });
   }
   return url;
@@ -198,6 +205,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
     range: DEFAULT_RANGE,
     ui: DEFAULT_UI_STATE,
     mode: null,
+    originPanelId: null,
   };
 
   if (!parsed) {
@@ -234,7 +242,8 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
       }
     : DEFAULT_UI_STATE;
 
-  return { datasource, queries, range, ui, mode };
+  const originPanelId = parsedSegments.filter(segment => isSegment(segment, 'originPanelId'))[0];
+  return { datasource, queries, range, ui, mode, originPanelId };
 }
 
 export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string {

+ 3 - 3
public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.ts

@@ -1,5 +1,6 @@
 import coreModule from 'app/core/core_module';
 import { DashboardSrv } from '../../services/DashboardSrv';
+import { CloneOptions } from '../../state/DashboardModel';
 
 const template = `
 <div class="modal-body">
@@ -95,7 +96,7 @@ export class SaveDashboardModalCtrl {
       return;
     }
 
-    const options: any = {
+    const options: CloneOptions = {
       saveVariables: this.saveVariables,
       saveTimerange: this.saveTimerange,
       message: this.message,
@@ -105,11 +106,10 @@ export class SaveDashboardModalCtrl {
     const saveModel = dashboard.getSaveModelClone(options);
 
     this.isSaving = true;
-
     return this.dashboardSrv.save(saveModel, options).then(this.postSave.bind(this, options));
   }
 
-  postSave(options: any) {
+  postSave(options?: { saveVariables?: boolean; saveTimerange?: boolean }) {
     if (options.saveVariables) {
       this.dashboardSrv.getCurrent().resetOriginalVariables();
     }

+ 1 - 1
public/app/features/dashboard/services/ChangeTracker.ts

@@ -65,7 +65,7 @@ export class ChangeTracker {
       return false;
     });
 
-    if (originalCopyDelay) {
+    if (originalCopyDelay && !dashboard.meta.fromExplore) {
       this.$timeout(() => {
         // wait for different services to patch the dashboard (missing properties)
         this.original = dashboard.getSaveModelClone();

+ 52 - 44
public/app/features/dashboard/services/DashboardSrv.ts

@@ -7,6 +7,13 @@ import { DashboardMeta } from 'app/types';
 import { BackendSrv } from 'app/core/services/backend_srv';
 import { ILocationService } from 'angular';
 
+interface DashboardSaveOptions {
+  folderId?: number;
+  overwrite?: boolean;
+  message?: string;
+  makeEditable?: boolean;
+}
+
 export class DashboardSrv {
   dashboard: DashboardModel;
 
@@ -37,54 +44,53 @@ export class DashboardSrv {
     removePanel(dashboard, dashboard.getPanelById(panelId), true);
   };
 
-  onPanelChangeView = (options: any) => {
+  onPanelChangeView = ({
+    fullscreen = false,
+    edit = false,
+    panelId,
+  }: {
+    fullscreen?: boolean;
+    edit?: boolean;
+    panelId?: number;
+  }) => {
     const urlParams = this.$location.search();
 
     // handle toggle logic
-    if (options.fullscreen === urlParams.fullscreen) {
-      // I hate using these truthy converters (!!) but in this case
-      // I think it's appropriate. edit can be null/false/undefined and
-      // here i want all of those to compare the same
-      if (!!options.edit === !!urlParams.edit) {
-        delete urlParams.fullscreen;
-        delete urlParams.edit;
-        delete urlParams.panelId;
-        delete urlParams.tab;
-        this.$location.search(urlParams);
-        return;
+    // I hate using these truthy converters (!!) but in this case
+    // I think it's appropriate. edit can be null/false/undefined and
+    // here i want all of those to compare the same
+    if (fullscreen === urlParams.fullscreen && edit === !!urlParams.edit) {
+      const paramsToRemove = ['fullscreen', 'edit', 'panelId', 'tab'];
+      for (const key of paramsToRemove) {
+        delete urlParams[key];
       }
-    }
 
-    if (options.fullscreen) {
-      urlParams.fullscreen = true;
-    } else {
-      delete urlParams.fullscreen;
-    }
-
-    if (options.edit) {
-      urlParams.edit = true;
-    } else {
-      delete urlParams.edit;
-      delete urlParams.tab;
+      this.$location.search(urlParams);
+      return;
     }
 
-    if (options.panelId || options.panelId === 0) {
-      urlParams.panelId = options.panelId;
-    } else {
-      delete urlParams.panelId;
-    }
+    const newUrlParams = {
+      ...urlParams,
+      fullscreen: fullscreen || undefined,
+      edit: edit || undefined,
+      tab: edit ? urlParams.tab : undefined,
+      panelId,
+    };
+
+    Object.keys(newUrlParams).forEach(key => {
+      if (newUrlParams[key] === undefined) {
+        delete newUrlParams[key];
+      }
+    });
 
-    this.$location.search(urlParams);
+    this.$location.search(newUrlParams);
   };
 
   handleSaveDashboardError(
     clone: any,
-    options: { overwrite?: any },
+    options: DashboardSaveOptions,
     err: { data: { status: string; message: any }; isHandled: boolean }
   ) {
-    options = options || {};
-    options.overwrite = true;
-
     if (err.data && err.data.status === 'version-mismatch') {
       err.isHandled = true;
 
@@ -129,16 +135,16 @@ export class DashboardSrv {
           this.showSaveAsModal();
         },
         onConfirm: () => {
-          this.save(clone, { overwrite: true });
+          this.save(clone, { ...options, overwrite: true });
         },
       });
     }
   }
 
-  postSave(clone: DashboardModel, data: { version: number; url: string }) {
+  postSave(data: { version: number; url: string }) {
     this.dashboard.version = data.version;
 
-    // important that these happens before location redirect below
+    // important that these happen before location redirect below
     this.$rootScope.appEvent('dashboard-saved', this.dashboard);
     this.$rootScope.appEvent('alert-success', ['Dashboard saved']);
 
@@ -152,17 +158,19 @@ export class DashboardSrv {
     return this.dashboard;
   }
 
-  save(clone: any, options: { overwrite?: any; folderId?: any }) {
-    options = options || {};
+  save(clone: any, options?: DashboardSaveOptions) {
     options.folderId = options.folderId >= 0 ? options.folderId : this.dashboard.meta.folderId || clone.folderId;
 
     return this.backendSrv
       .saveDashboard(clone, options)
-      .then(this.postSave.bind(this, clone))
-      .catch(this.handleSaveDashboardError.bind(this, clone, options));
+      .then((data: any) => this.postSave(data))
+      .catch(this.handleSaveDashboardError.bind(this, clone, { folderId: options.folderId }));
   }
 
-  saveDashboard(options?: { overwrite?: any; folderId?: any; makeEditable?: any }, clone?: DashboardModel) {
+  saveDashboard(
+    clone?: DashboardModel,
+    { makeEditable = false, folderId, overwrite = false, message }: DashboardSaveOptions = {}
+  ) {
     if (clone) {
       this.setCurrent(this.create(clone, this.dashboard.meta));
     }
@@ -171,7 +179,7 @@ export class DashboardSrv {
       return this.showDashboardProvisionedModal();
     }
 
-    if (!this.dashboard.meta.canSave && options.makeEditable !== true) {
+    if (!(this.dashboard.meta.canSave || makeEditable)) {
       return Promise.resolve();
     }
 
@@ -183,7 +191,7 @@ export class DashboardSrv {
       return this.showSaveModal();
     }
 
-    return this.save(this.dashboard.getSaveModelClone(), options);
+    return this.save(this.dashboard.getSaveModelClone(), { folderId, overwrite, message });
   }
 
   saveJSONDashboard(json: string) {

+ 104 - 1
public/app/features/dashboard/state/initDashboard.test.ts

@@ -4,6 +4,7 @@ import { initDashboard, InitDashboardArgs } from './initDashboard';
 import { DashboardRouteInfo } from 'app/types';
 import { getBackendSrv } from 'app/core/services/backend_srv';
 import { dashboardInitFetching, dashboardInitCompleted, dashboardInitServices } from './actions';
+import { resetExploreAction } from 'app/features/explore/state/actionTypes';
 
 jest.mock('app/core/services/backend_srv');
 
@@ -16,6 +17,7 @@ interface ScenarioContext {
   unsavedChangesSrv: any;
   variableSrv: any;
   dashboardSrv: any;
+  loaderSrv: any;
   keybindingSrv: any;
   backendSrv: any;
   setup: (fn: () => void) => void;
@@ -33,6 +35,33 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) {
     const variableSrv = { init: jest.fn() };
     const dashboardSrv = { setCurrent: jest.fn() };
     const keybindingSrv = { setupDashboardBindings: jest.fn() };
+    const loaderSrv = {
+      loadDashboard: jest.fn(() => ({
+        meta: {
+          canStar: false,
+          canShare: false,
+          isNew: true,
+          folderId: 0,
+        },
+        dashboard: {
+          title: 'My cool dashboard',
+          panels: [
+            {
+              type: 'add-panel',
+              gridPos: { x: 0, y: 0, w: 12, h: 9 },
+              title: 'Panel Title',
+              id: 2,
+              targets: [
+                {
+                  refId: 'A',
+                  expr: 'old expr',
+                },
+              ],
+            },
+          ],
+        },
+      })),
+    };
 
     const injectorMock = {
       get: (name: string) => {
@@ -41,6 +70,8 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) {
             return timeSrv;
           case 'annotationsSrv':
             return annotationsSrv;
+          case 'dashboardLoaderSrv':
+            return loaderSrv;
           case 'unsavedChangesSrv':
             return unsavedChangesSrv;
           case 'dashboardSrv':
@@ -71,12 +102,19 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) {
       variableSrv,
       dashboardSrv,
       keybindingSrv,
+      loaderSrv,
       actions: [],
       storeState: {
         location: {
           query: {},
         },
         user: {},
+        explore: {
+          left: {
+            originPanelId: undefined,
+            queries: [],
+          },
+        },
       },
       setup: (fn: () => void) => {
         setupFn = fn;
@@ -121,7 +159,7 @@ describeInitScenario('Initializing new dashboard', ctx => {
     expect(ctx.actions[3].payload.title).toBe('New dashboard');
   });
 
-  it('Should Initializing services', () => {
+  it('Should initialize services', () => {
     expect(ctx.timeSrv.init).toBeCalled();
     expect(ctx.annotationsSrv.init).toBeCalled();
     expect(ctx.variableSrv.init).toBeCalled();
@@ -146,3 +184,68 @@ describeInitScenario('Initializing home dashboard', ctx => {
     expect(ctx.actions[1].payload.path).toBe('/u/123/my-home');
   });
 });
+
+describeInitScenario('Initializing existing dashboard', ctx => {
+  const mockQueries = [
+    {
+      context: 'explore',
+      key: 'jdasldsa98dsa9',
+      refId: 'A',
+      expr: 'new expr',
+    },
+    {
+      context: 'explore',
+      key: 'fdsjkfds78fd',
+      refId: 'B',
+    },
+  ];
+
+  const expectedQueries = mockQueries.map(query => ({ refId: query.refId, expr: query.expr }));
+
+  ctx.setup(() => {
+    ctx.storeState.user.orgId = 12;
+    ctx.storeState.explore.left.originPanelId = 2;
+    ctx.storeState.explore.left.queries = mockQueries;
+  });
+
+  it('Should send action dashboardInitFetching', () => {
+    expect(ctx.actions[0].type).toBe(dashboardInitFetching.type);
+  });
+
+  it('Should send action dashboardInitServices ', () => {
+    expect(ctx.actions[1].type).toBe(dashboardInitServices.type);
+  });
+
+  it('Should update location with orgId query param', () => {
+    expect(ctx.actions[2].type).toBe('UPDATE_LOCATION');
+    expect(ctx.actions[2].payload.query.orgId).toBe(12);
+  });
+
+  it('Should send resetExploreAction when coming from explore', () => {
+    expect(ctx.actions[3].type).toBe(resetExploreAction.type);
+    expect(ctx.actions[3].payload.force).toBe(true);
+    expect(ctx.dashboardSrv.setCurrent).lastCalledWith(
+      expect.objectContaining({
+        panels: expect.arrayContaining([
+          expect.objectContaining({
+            targets: expectedQueries,
+          }),
+        ]),
+      })
+    );
+  });
+
+  it('Should send action dashboardInitCompleted', () => {
+    expect(ctx.actions[4].type).toBe(dashboardInitCompleted.type);
+    expect(ctx.actions[4].payload.title).toBe('My cool dashboard');
+  });
+
+  it('Should initialize services', () => {
+    expect(ctx.timeSrv.init).toBeCalled();
+    expect(ctx.annotationsSrv.init).toBeCalled();
+    expect(ctx.variableSrv.init).toBeCalled();
+    expect(ctx.unsavedChangesSrv.init).toBeCalled();
+    expect(ctx.keybindingSrv.setupDashboardBindings).toBeCalled();
+    expect(ctx.dashboardSrv.setCurrent).toBeCalled();
+  });
+});

+ 36 - 1
public/app/features/dashboard/state/initDashboard.ts

@@ -21,8 +21,10 @@ import {
 } from './actions';
 
 // Types
-import { DashboardRouteInfo, StoreState, ThunkDispatch, ThunkResult, DashboardDTO } from 'app/types';
+import { DashboardRouteInfo, StoreState, ThunkDispatch, ThunkResult, DashboardDTO, ExploreItemState } from 'app/types';
 import { DashboardModel } from './DashboardModel';
+import { resetExploreAction } from 'app/features/explore/state/actionTypes';
+import { DataQuery } from '@grafana/ui';
 
 export interface InitDashboardArgs {
   $injector: any;
@@ -171,6 +173,9 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
     timeSrv.init(dashboard);
     annotationsSrv.init(dashboard);
 
+    const left = storeState.explore && storeState.explore.left;
+    dashboard.meta.fromExplore = !!(left && left.originPanelId);
+
     // template values service needs to initialize completely before
     // the rest of the dashboard can load
     try {
@@ -198,8 +203,13 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
       console.log(err);
     }
 
+    if (dashboard.meta.fromExplore) {
+      updateQueriesWhenComingFromExplore(dispatch, dashboard, left);
+    }
+
     // legacy srv state
     dashboardSrv.setCurrent(dashboard);
+
     // yay we are done
     dispatch(dashboardInitCompleted(dashboard));
   };
@@ -231,3 +241,28 @@ function getNewDashboardModelData(urlFolderId?: string): any {
 
   return data;
 }
+
+function updateQueriesWhenComingFromExplore(
+  dispatch: ThunkDispatch,
+  dashboard: DashboardModel,
+  left: ExploreItemState
+) {
+  // When returning to the origin panel from explore, if we're doing
+  // so with changes all the explore state is reset _except_ the queries
+  // and the origin panel ID.
+  const panelArrId = dashboard.panels.findIndex(panel => panel.id === left.originPanelId);
+
+  if (panelArrId > -1) {
+    dashboard.panels[panelArrId].targets = left.queries.map((query: DataQuery & { context?: string }) => {
+      delete query.context;
+      delete query.key;
+      return query;
+    });
+  }
+
+  dashboard.startRefresh();
+
+  // Force-reset explore so that on subsequent dashboard loads we aren't
+  // taking the modified queries from explore again.
+  dispatch(resetExploreAction({ force: true }));
+}

+ 16 - 3
public/app/features/explore/Explore.tsx

@@ -90,6 +90,7 @@ interface ExploreProps {
   onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
   toggleGraph: typeof toggleGraph;
   queryResponse: PanelData;
+  originPanelId: number;
 }
 
 /**
@@ -126,7 +127,16 @@ export class Explore extends React.PureComponent<ExploreProps> {
   }
 
   componentDidMount() {
-    const { initialized, exploreId, initialDatasource, initialQueries, initialRange, mode, initialUI } = this.props;
+    const {
+      initialized,
+      exploreId,
+      initialDatasource,
+      initialQueries,
+      initialRange,
+      mode,
+      initialUI,
+      originPanelId,
+    } = this.props;
     const width = this.el ? this.el.offsetWidth : 0;
 
     // initialize the whole explore first time we mount and if browser history contains a change in datasource
@@ -139,7 +149,8 @@ export class Explore extends React.PureComponent<ExploreProps> {
         mode,
         width,
         this.exploreEvents,
-        initialUI
+        initialUI,
+        originPanelId
       );
     }
   }
@@ -351,7 +362,8 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
     queryResponse,
   } = item;
 
-  const { datasource, queries, range: urlRange, mode: urlMode, ui } = (urlState || {}) as ExploreUrlState;
+  const { datasource, queries, range: urlRange, mode: urlMode, ui, originPanelId } = (urlState ||
+    {}) as ExploreUrlState;
   const initialDatasource = datasource || store.get(lastUsedDatasourceKeyForOrgId(state.user.orgId));
   const initialQueries: DataQuery[] = ensureQueriesMemoized(queries);
   const initialRange = urlRange ? getTimeRangeFromUrlMemoized(urlRange, timeZone).raw : DEFAULT_RANGE;
@@ -397,6 +409,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
     showingTable,
     absoluteRange,
     queryResponse,
+    originPanelId,
   };
 }
 

+ 67 - 1
public/app/features/explore/ExploreToolbar.tsx

@@ -1,10 +1,12 @@
+import omitBy from 'lodash/omitBy';
 import React, { PureComponent } from 'react';
 import { connect } from 'react-redux';
 import { hot } from 'react-hot-loader';
 import memoizeOne from 'memoize-one';
+import classNames from 'classnames';
 
 import { ExploreId, ExploreMode } from 'app/types/explore';
-import { DataSourceSelectItem, ToggleButtonGroup, ToggleButton } from '@grafana/ui';
+import { DataSourceSelectItem, ToggleButtonGroup, ToggleButton, DataQuery, Tooltip, ButtonSelect } from '@grafana/ui';
 import { RawTimeRange, TimeZone, TimeRange, SelectableValue } from '@grafana/data';
 import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
 import { StoreState } from 'app/types/store';
@@ -16,8 +18,12 @@ import {
   splitOpen,
   changeRefreshInterval,
   changeMode,
+  clearOrigin,
 } from './state/actions';
+import { updateLocation } from 'app/core/actions';
 import { getTimeZone } from '../profile/state/selectors';
+import { getDashboardSrv } from '../dashboard/services/DashboardSrv';
+import kbn from '../../core/utils/kbn';
 import { ExploreTimeControls } from './ExploreTimeControls';
 
 enum IconSide {
@@ -71,6 +77,8 @@ interface StateProps {
   selectedModeOption: SelectableValue<ExploreMode>;
   hasLiveOption: boolean;
   isLive: boolean;
+  originPanelId: number;
+  queries: DataQuery[];
 }
 
 interface DispatchProps {
@@ -81,6 +89,8 @@ interface DispatchProps {
   split: typeof splitOpen;
   changeRefreshInterval: typeof changeRefreshInterval;
   changeMode: typeof changeMode;
+  clearOrigin: typeof clearOrigin;
+  updateLocation: typeof updateLocation;
 }
 
 type Props = StateProps & DispatchProps & OwnProps;
@@ -112,6 +122,31 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
     changeMode(exploreId, mode);
   };
 
+  returnToPanel = async ({ withChanges = false } = {}) => {
+    const { originPanelId } = this.props;
+
+    const dashboardSrv = getDashboardSrv();
+    const dash = dashboardSrv.getCurrent();
+    const titleSlug = kbn.slugifyForUrl(dash.title);
+
+    if (!withChanges) {
+      this.props.clearOrigin();
+    }
+
+    const dashViewOptions = {
+      fullscreen: withChanges || dash.meta.fullscreen,
+      edit: withChanges || dash.meta.isEditing,
+    };
+
+    this.props.updateLocation({
+      path: `/d/${dash.uid}/:${titleSlug}`,
+      query: {
+        ...omitBy(dashViewOptions, v => !v),
+        panelId: originPanelId,
+      },
+    });
+  };
+
   render() {
     const {
       datasourceMissing,
@@ -130,8 +165,15 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
       selectedModeOption,
       hasLiveOption,
       isLive,
+      originPanelId,
     } = this.props;
 
+    const originDashboardIsEditable = Number.isInteger(originPanelId);
+    const panelReturnClasses = classNames('btn', 'navbar-button', {
+      'btn--radius-right-0': originDashboardIsEditable,
+      'navbar-button navbar-button--border-right-0': originDashboardIsEditable,
+    });
+
     return (
       <div className={splitted ? 'explore-toolbar splitted' : 'explore-toolbar'}>
         <div className="explore-toolbar-item">
@@ -187,6 +229,24 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
               </div>
             ) : null}
 
+            {Number.isInteger(originPanelId) && !splitted && (
+              <div className="explore-toolbar-content-item">
+                <Tooltip content={'Return to panel'} placement="bottom">
+                  <button className={panelReturnClasses} onClick={() => this.returnToPanel()}>
+                    <i className="fa fa-arrow-left" />
+                  </button>
+                </Tooltip>
+                {originDashboardIsEditable && (
+                  <ButtonSelect
+                    className="navbar-button--attached btn--radius-left-0$"
+                    options={[{ label: 'Return to panel with changes', value: '' }]}
+                    onChange={() => this.returnToPanel({ withChanges: true })}
+                    maxMenuHeight={380}
+                  />
+                )}
+              </div>
+            )}
+
             {exploreId === 'left' && !splitted ? (
               <div className="explore-toolbar-content-item">
                 {createResponsiveButton({
@@ -285,6 +345,8 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
     supportedModes,
     mode,
     isLive,
+    originPanelId,
+    queries,
   } = exploreItem;
   const selectedDatasource = datasourceInstance
     ? exploreDatasources.find(datasource => datasource.name === datasourceInstance.name)
@@ -307,17 +369,21 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
     selectedModeOption,
     hasLiveOption,
     isLive,
+    originPanelId,
+    queries,
   };
 };
 
 const mapDispatchToProps: DispatchProps = {
   changeDatasource,
+  updateLocation,
   changeRefreshInterval,
   clearAll: clearQueries,
   runQueries,
   closeSplit: splitClose,
   split: splitOpen,
   changeMode: changeMode,
+  clearOrigin,
 };
 
 export const ExploreToolbar = hot(module)(

+ 1 - 1
public/app/features/explore/Wrapper.tsx

@@ -16,7 +16,7 @@ interface WrapperProps {
 
 export class Wrapper extends Component<WrapperProps> {
   componentWillUnmount() {
-    this.props.resetExploreAction();
+    this.props.resetExploreAction({});
   }
 
   render() {

+ 20 - 2
public/app/features/explore/state/actionTypes.ts

@@ -4,7 +4,7 @@ import { DataQuery, DataSourceSelectItem, DataSourceApi, QueryFixAction, PanelDa
 
 import { LogLevel, TimeRange, LoadingState, AbsoluteTimeRange } from '@grafana/data';
 import { ExploreId, ExploreItemState, HistoryItem, ExploreUIState, ExploreMode } from 'app/types/explore';
-import { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFactory';
+import { actionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFactory';
 
 /**  Higher order actions
  *
@@ -61,6 +61,14 @@ export interface ClearQueriesPayload {
   exploreId: ExploreId;
 }
 
+export interface ClearOriginPayload {
+  exploreId: ExploreId;
+}
+
+export interface ClearRefreshIntervalPayload {
+  exploreId: ExploreId;
+}
+
 export interface HighlightLogsExpressionPayload {
   exploreId: ExploreId;
   expressions: string[];
@@ -74,6 +82,7 @@ export interface InitializeExplorePayload {
   range: TimeRange;
   mode: ExploreMode;
   ui: ExploreUIState;
+  originPanelId: number;
 }
 
 export interface LoadDatasourceMissingPayload {
@@ -202,6 +211,10 @@ export interface SetPausedStatePayload {
   isPaused: boolean;
 }
 
+export interface ResetExplorePayload {
+  force?: boolean;
+}
+
 /**
  * Adds a query row after the row with the given index.
  */
@@ -236,6 +249,11 @@ export const changeRefreshIntervalAction = actionCreatorFactory<ChangeRefreshInt
  */
 export const clearQueriesAction = actionCreatorFactory<ClearQueriesPayload>('explore/CLEAR_QUERIES').create();
 
+/**
+ * Clear origin panel id.
+ */
+export const clearOriginAction = actionCreatorFactory<ClearOriginPayload>('explore/CLEAR_ORIGIN').create();
+
 /**
  * Highlight expressions in the log results
  */
@@ -351,7 +369,7 @@ export const toggleLogLevelAction = actionCreatorFactory<ToggleLogLevelPayload>(
 /**
  * Resets state for explore.
  */
-export const resetExploreAction = noPayloadActionCreatorFactory('explore/RESET_EXPLORE').create();
+export const resetExploreAction = actionCreatorFactory<ResetExplorePayload>('explore/RESET_EXPLORE').create();
 export const queriesImportedAction = actionCreatorFactory<QueriesImportedPayload>('explore/QueriesImported').create();
 export const testDataSourcePendingAction = actionCreatorFactory<TestDatasourcePendingPayload>(
   'explore/TEST_DATASOURCE_PENDING'

+ 24 - 3
public/app/features/explore/state/actions.ts

@@ -69,6 +69,7 @@ import {
   historyUpdatedAction,
   queryEndedAction,
   queryStreamUpdatedAction,
+  clearOriginAction,
 } from './actionTypes';
 import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
 import { getTimeZone } from 'app/features/profile/state/selectors';
@@ -208,6 +209,12 @@ export function clearQueries(exploreId: ExploreId): ThunkResult<void> {
   };
 }
 
+export function clearOrigin(): ThunkResult<void> {
+  return dispatch => {
+    dispatch(clearOriginAction({ exploreId: ExploreId.left }));
+  };
+}
+
 /**
  * Loads all explore data sources and sets the chosen datasource.
  * If there are no datasources a missing datasource action is dispatched.
@@ -250,7 +257,8 @@ export function initializeExplore(
   mode: ExploreMode,
   containerWidth: number,
   eventBridge: Emitter,
-  ui: ExploreUIState
+  ui: ExploreUIState,
+  originPanelId: number
 ): ThunkResult<void> {
   return async (dispatch, getState) => {
     const timeZone = getTimeZone(getState().user);
@@ -265,6 +273,7 @@ export function initializeExplore(
         range,
         mode,
         ui,
+        originPanelId,
       })
     );
     dispatch(updateTime({ exploreId }));
@@ -722,7 +731,7 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
     }
 
     const { urlState, update, containerWidth, eventBridge } = itemState;
-    const { datasource, queries, range: urlRange, mode, ui } = urlState;
+    const { datasource, queries, range: urlRange, mode, ui, originPanelId } = urlState;
     const refreshQueries: DataQuery[] = [];
     for (let index = 0; index < queries.length; index++) {
       const query = queries[index];
@@ -734,7 +743,19 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
     // need to refresh datasource
     if (update.datasource) {
       const initialQueries = ensureQueries(queries);
-      dispatch(initializeExplore(exploreId, datasource, initialQueries, range, mode, containerWidth, eventBridge, ui));
+      dispatch(
+        initializeExplore(
+          exploreId,
+          datasource,
+          initialQueries,
+          range,
+          mode,
+          containerWidth,
+          eventBridge,
+          ui,
+          originPanelId
+        )
+      );
       return;
     }
 

+ 31 - 2
public/app/features/explore/state/reducers.ts

@@ -28,6 +28,10 @@ import {
   scanStopAction,
   queryStartAction,
   changeRangeAction,
+  clearOriginAction,
+} from './actionTypes';
+
+import {
   addQueryRowAction,
   changeQueryAction,
   changeSizeAction,
@@ -235,6 +239,15 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
       };
     },
   })
+  .addMapper({
+    filter: clearOriginAction,
+    mapper: (state): ExploreItemState => {
+      return {
+        ...state,
+        originPanelId: undefined,
+      };
+    },
+  })
   .addMapper({
     filter: highlightLogsExpressionAction,
     mapper: (state, action): ExploreItemState => {
@@ -245,7 +258,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
   .addMapper({
     filter: initializeExploreAction,
     mapper: (state, action): ExploreItemState => {
-      const { containerWidth, eventBridge, queries, range, mode, ui } = action.payload;
+      const { containerWidth, eventBridge, queries, range, mode, ui, originPanelId } = action.payload;
       return {
         ...state,
         containerWidth,
@@ -256,6 +269,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
         initialized: true,
         queryKeys: getQueryKeys(queries, state.datasourceInstance),
         ...ui,
+        originPanelId,
         update: makeInitialUpdateState(),
       };
     },
@@ -265,6 +279,9 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
     mapper: (state, action): ExploreItemState => {
       const { datasourceInstance } = action.payload;
       const [supportedModes, mode] = getModesForDatasource(datasourceInstance, state.mode);
+
+      const originPanelId = state.urlState && state.urlState.originPanelId;
+
       // Custom components
       const StartPage = datasourceInstance.components.ExploreStartPage;
       stopQueryState(state.queryState, 'Datasource changed');
@@ -283,6 +300,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
         queryKeys: [],
         supportedModes,
         mode,
+        originPanelId,
       };
     },
   })
@@ -704,7 +722,18 @@ export const exploreReducer = (state = initialExploreState, action: HigherOrderA
     }
 
     case ActionTypes.ResetExplore: {
-      return initialExploreState;
+      if (action.payload.force || !Number.isInteger(state.left.originPanelId)) {
+        return initialExploreState;
+      }
+
+      return {
+        ...initialExploreState,
+        left: {
+          ...initialExploreItemState,
+          queries: state.left.queries,
+          originPanelId: state.left.originPanelId,
+        },
+      };
     }
 
     case updateLocation.type: {

+ 8 - 1
public/app/features/panel/metrics_panel_ctrl.ts

@@ -255,11 +255,18 @@ class MetricsPanelCtrl extends PanelCtrl {
         text: 'Explore',
         icon: 'gicon gicon-explore',
         shortcut: 'x',
-        href: await getExploreUrl(this.panel.targets, this.datasource, this.datasourceSrv, this.timeSrv),
+        href: await getExploreUrl(this.panel, this.panel.targets, this.datasource, this.datasourceSrv, this.timeSrv),
       });
     }
     return items;
   }
+
+  async explore() {
+    const url = await getExploreUrl(this.panel, this.panel.targets, this.datasource, this.datasourceSrv, this.timeSrv);
+    if (url) {
+      this.$timeout(() => this.$location.url(url));
+    }
+  }
 }
 
 export { MetricsPanelCtrl };

+ 11 - 10
public/app/plugins/datasource/prometheus/datasource.ts

@@ -25,10 +25,10 @@ import {
   DataQueryResponseData,
   DataStreamState,
 } from '@grafana/ui';
-import { ExploreUrlState } from 'app/types/explore';
 import { safeStringifyValue } from 'app/core/utils/explore';
 import { TemplateSrv } from 'app/features/templating/template_srv';
 import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
+import { ExploreUrlState } from 'app/types';
 
 export interface PromDataQueryResponse {
   data: {
@@ -603,15 +603,16 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
   getExploreState(queries: PromQuery[]): Partial<ExploreUrlState> {
     let state: Partial<ExploreUrlState> = { datasource: this.name };
     if (queries && queries.length > 0) {
-      const expandedQueries = queries.map(query => ({
-        ...query,
-        expr: this.templateSrv.replace(query.expr, {}, this.interpolateQueryExpr),
-        context: 'explore',
-
-        // null out values we don't support in Explore yet
-        legendFormat: null,
-        step: null,
-      }));
+      const expandedQueries = queries.map(query => {
+        const expandedQuery = {
+          ...query,
+          expr: this.templateSrv.replace(query.expr, {}, this.interpolateQueryExpr),
+          context: 'explore',
+        };
+
+        return expandedQuery;
+      });
+
       state = {
         ...state,
         queries: expandedQueries,

+ 1 - 0
public/app/types/dashboard.ts

@@ -22,6 +22,7 @@ export interface DashboardMeta {
   url?: string;
   folderId?: number;
   fullscreen?: boolean;
+  fromExplore?: boolean;
   isEditing?: boolean;
   canMakeEditable?: boolean;
   submenuEnabled?: boolean;

+ 2 - 0
public/app/types/explore.ts

@@ -266,6 +266,7 @@ export interface ExploreItemState {
   queryState: PanelQueryState;
 
   queryResponse: PanelData;
+  originPanelId?: number;
 }
 
 export interface ExploreUpdateState {
@@ -289,6 +290,7 @@ export interface ExploreUrlState {
   mode: ExploreMode;
   range: RawTimeRange;
   ui: ExploreUIState;
+  originPanelId?: number;
 }
 
 export interface HistoryItem<TQuery extends DataQuery = DataQuery> {