Browse Source

Merge branch 'master' into 14812/formgroup-component

Peter Holmberg 7 years ago
parent
commit
0d95527924
54 changed files with 2828 additions and 1468 deletions
  1. 4 3
      packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx
  2. 0 4
      packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap
  3. 1 1
      packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap
  4. 1 1
      packages/grafana-ui/src/components/Label/Label.tsx
  5. 2 2
      packages/grafana-ui/src/components/Select/Select.tsx
  6. 126 16
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx
  7. 89 105
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
  8. 75 66
      packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss
  9. 23 23
      packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx
  10. 14 16
      packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx
  11. 29 23
      packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx
  12. 0 0
      packages/grafana-ui/src/components/ValueMappingsEditor/_ValueMappingsEditor.scss
  13. 6 6
      packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap
  14. 1 0
      packages/grafana-ui/src/components/index.scss
  15. 1 0
      packages/grafana-ui/src/components/index.ts
  16. 0 16
      packages/grafana-ui/src/types/gauge.ts
  17. 0 1
      packages/grafana-ui/src/types/index.ts
  18. 5 0
      packages/grafana-ui/src/types/panel.ts
  19. 5 2
      pkg/components/imguploader/imguploader.go
  20. 2 20
      pkg/log/log.go
  21. 5 1
      pkg/login/ext_user.go
  22. 1 1
      pkg/services/alerting/notifiers/telegram.go
  23. 5 2
      pkg/services/alerting/test_notification.go
  24. 20 9
      public/app/core/directives/dropdown_typeahead.ts
  25. 22 50
      public/app/core/utils/explore.test.ts
  26. 85 22
      public/app/core/utils/explore.ts
  27. 7 1
      public/app/features/dashboard/dashgrid/PanelChrome.tsx
  28. 5 2
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
  29. 113 788
      public/app/features/explore/Explore.tsx
  30. 61 0
      public/app/features/explore/GraphContainer.tsx
  31. 3 3
      public/app/features/explore/Logs.tsx
  32. 91 0
      public/app/features/explore/LogsContainer.tsx
  33. 1 1
      public/app/features/explore/QueryEditor.tsx
  34. 163 0
      public/app/features/explore/QueryRow.tsx
  35. 9 143
      public/app/features/explore/QueryRows.tsx
  36. 49 0
      public/app/features/explore/TableContainer.tsx
  37. 31 63
      public/app/features/explore/Wrapper.tsx
  38. 302 0
      public/app/features/explore/state/actionTypes.ts
  39. 757 0
      public/app/features/explore/state/actions.ts
  40. 462 0
      public/app/features/explore/state/reducers.ts
  41. 1 1
      public/app/features/teams/TeamSettings.tsx
  42. 1 1
      public/app/features/templating/variable_srv.ts
  43. 2 2
      public/app/plugins/panel/gauge/GaugeOptionsEditor.tsx
  44. 8 2
      public/app/plugins/panel/gauge/GaugePanel.tsx
  45. 16 5
      public/app/plugins/panel/gauge/GaugePanelOptions.tsx
  46. 2 1
      public/app/plugins/panel/gauge/ValueOptions.tsx
  47. 15 1
      public/app/plugins/panel/gauge/types.ts
  48. 2 0
      public/app/store/configureStore.ts
  49. 188 37
      public/app/types/explore.ts
  50. 2 0
      public/app/types/index.ts
  51. 1 1
      public/app/viz/Gauge.test.tsx
  52. 12 23
      public/app/viz/Gauge.tsx
  53. 1 2
      public/sass/_grafana.scss
  54. 1 1
      public/sass/pages/_explore.scss

+ 4 - 3
packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx

@@ -6,6 +6,7 @@ interface Props {
   autoHide?: boolean;
   autoHide?: boolean;
   autoHideTimeout?: number;
   autoHideTimeout?: number;
   autoHideDuration?: number;
   autoHideDuration?: number;
+  autoMaxHeight?: string;
   hideTracksWhenNotNeeded?: boolean;
   hideTracksWhenNotNeeded?: boolean;
 }
 }
 
 
@@ -18,11 +19,12 @@ export class CustomScrollbar extends PureComponent<Props> {
     autoHide: true,
     autoHide: true,
     autoHideTimeout: 200,
     autoHideTimeout: 200,
     autoHideDuration: 200,
     autoHideDuration: 200,
+    autoMaxHeight: '100%',
     hideTracksWhenNotNeeded: false,
     hideTracksWhenNotNeeded: false,
   };
   };
 
 
   render() {
   render() {
-    const { customClassName, children, ...scrollProps } = this.props;
+    const { customClassName, children, autoMaxHeight } = this.props;
 
 
     return (
     return (
       <Scrollbars
       <Scrollbars
@@ -31,13 +33,12 @@ export class CustomScrollbar extends PureComponent<Props> {
         // These autoHeightMin & autoHeightMax options affect firefox and chrome differently.
         // These autoHeightMin & autoHeightMax options affect firefox and chrome differently.
         // Before these where set to inhert but that caused problems with cut of legends in firefox
         // Before these where set to inhert but that caused problems with cut of legends in firefox
         autoHeightMin={'0'}
         autoHeightMin={'0'}
-        autoHeightMax={'100%'}
+        autoHeightMax={autoMaxHeight}
         renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
         renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
         renderTrackVertical={props => <div {...props} className="track-vertical" />}
         renderTrackVertical={props => <div {...props} className="track-vertical" />}
         renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}
         renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}
         renderThumbVertical={props => <div {...props} className="thumb-vertical" />}
         renderThumbVertical={props => <div {...props} className="thumb-vertical" />}
         renderView={props => <div {...props} className="view" />}
         renderView={props => <div {...props} className="view" />}
-        {...scrollProps}
       >
       >
         {children}
         {children}
       </Scrollbars>
       </Scrollbars>

+ 0 - 4
packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap

@@ -42,9 +42,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
       Object {
       Object {
         "display": "none",
         "display": "none",
         "height": 6,
         "height": 6,
-        "opacity": 0,
         "position": "absolute",
         "position": "absolute",
-        "transition": "opacity 200ms",
       }
       }
     }
     }
   >
   >
@@ -64,9 +62,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
     style={
     style={
       Object {
       Object {
         "display": "none",
         "display": "none",
-        "opacity": 0,
         "position": "absolute",
         "position": "absolute",
-        "transition": "opacity 200ms",
         "width": 6,
         "width": 6,
       }
       }
     }
     }

+ 1 - 1
packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap

@@ -2,7 +2,7 @@
 
 
 exports[`Render should render component 1`] = `
 exports[`Render should render component 1`] = `
 <div
 <div
-  className="gf-form"
+  className="form-field"
 >
 >
   <Component
   <Component
     width={11}
     width={11}

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

@@ -1,5 +1,5 @@
 import React, { SFC, ReactNode } from 'react';
 import React, { SFC, ReactNode } from 'react';
-import { Tooltip } from '..';
+import { Tooltip } from '../Tooltip/Tooltip';
 
 
 interface Props {
 interface Props {
   tooltip?: string;
   tooltip?: string;

+ 2 - 2
packages/grafana-ui/src/components/Select/Select.tsx

@@ -16,7 +16,7 @@ import SelectOptionGroup from './SelectOptionGroup';
 import IndicatorsContainer from './IndicatorsContainer';
 import IndicatorsContainer from './IndicatorsContainer';
 import NoOptionsMessage from './NoOptionsMessage';
 import NoOptionsMessage from './NoOptionsMessage';
 import resetSelectStyles from './resetSelectStyles';
 import resetSelectStyles from './resetSelectStyles';
-import { CustomScrollbar } from '@grafana/ui';
+import { CustomScrollbar } from '..';
 
 
 export interface SelectOptionItem {
 export interface SelectOptionItem {
   label?: string;
   label?: string;
@@ -61,7 +61,7 @@ interface AsyncProps {
 export const MenuList = (props: any) => {
 export const MenuList = (props: any) => {
   return (
   return (
     <components.MenuList {...props}>
     <components.MenuList {...props}>
-      <CustomScrollbar autoHide={false}>{props.children}</CustomScrollbar>
+      <CustomScrollbar autoHide={false} autoMaxHeight="inherit">{props.children}</CustomScrollbar>
     </components.MenuList>
     </components.MenuList>
   );
   );
 };
 };

+ 126 - 16
packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx

@@ -2,7 +2,6 @@ import React from 'react';
 import { shallow } from 'enzyme';
 import { shallow } from 'enzyme';
 
 
 import { ThresholdsEditor, Props } from './ThresholdsEditor';
 import { ThresholdsEditor, Props } from './ThresholdsEditor';
-import { BasicGaugeColor } from '../../types';
 
 
 const setup = (propOverrides?: object) => {
 const setup = (propOverrides?: object) => {
   const props: Props = {
   const props: Props = {
@@ -15,49 +14,160 @@ const setup = (propOverrides?: object) => {
   return shallow(<ThresholdsEditor {...props} />).instance() as ThresholdsEditor;
   return shallow(<ThresholdsEditor {...props} />).instance() as ThresholdsEditor;
 };
 };
 
 
+describe('Initialization', () => {
+  it('should add a base threshold if missing', () => {
+    const instance = setup();
+
+    expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
+  });
+});
+
 describe('Add threshold', () => {
 describe('Add threshold', () => {
-  it('should add threshold', () => {
+  it('should not add threshold at index 0', () => {
     const instance = setup();
     const instance = setup();
 
 
     instance.onAddThreshold(0);
     instance.onAddThreshold(0);
 
 
-    expect(instance.state.thresholds).toEqual([{ index: 0, value: 50, color: 'rgb(127, 115, 64)' }]);
+    expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
+  });
+
+  it('should add threshold', () => {
+    const instance = setup();
+
+    instance.onAddThreshold(1);
+
+    expect(instance.state.thresholds).toEqual([
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+    ]);
   });
   });
 
 
   it('should add another threshold above a first', () => {
   it('should add another threshold above a first', () => {
     const instance = setup({
     const instance = setup({
-      thresholds: [{ index: 0, value: 50, color: 'rgb(127, 115, 64)' }],
+      thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }, { index: 1, value: 50, color: '#EAB839' }],
     });
     });
 
 
-    instance.onAddThreshold(1);
+    instance.onAddThreshold(2);
+
+    expect(instance.state.thresholds).toEqual([
+      { index: 2, value: 75, color: '#6ED0E0' },
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+    ]);
+  });
+
+  it('should add another threshold between first and second index', () => {
+    const instance = setup({
+      thresholds: [
+        { index: 0, value: -Infinity, color: '#7EB26D' },
+        { index: 1, value: 50, color: '#EAB839' },
+        { index: 2, value: 75, color: '#6ED0E0' },
+      ],
+    });
+
+    instance.onAddThreshold(2);
 
 
     expect(instance.state.thresholds).toEqual([
     expect(instance.state.thresholds).toEqual([
-      { index: 1, value: 75, color: 'rgb(170, 95, 61)' },
-      { index: 0, value: 50, color: 'rgb(127, 115, 64)' },
+      { index: 3, value: 75, color: '#6ED0E0' },
+      { index: 2, value: 62.5, color: '#EF843C' },
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+    ]);
+  });
+});
+
+describe('Remove threshold', () => {
+  it('should not remove threshold at index 0', () => {
+    const thresholds = [
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 2, value: 75, color: '#6ED0E0' },
+    ];
+    const instance = setup({ thresholds });
+
+    instance.onRemoveThreshold(thresholds[0]);
+
+    expect(instance.state.thresholds).toEqual(thresholds);
+  });
+
+  it('should remove threshold', () => {
+    const thresholds = [
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 2, value: 75, color: '#6ED0E0' },
+    ];
+    const instance = setup({
+      thresholds,
+    });
+
+    instance.onRemoveThreshold(thresholds[1]);
+
+    expect(instance.state.thresholds).toEqual([
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+      { index: 1, value: 75, color: '#6ED0E0' },
     ]);
     ]);
   });
   });
 });
 });
 
 
 describe('change threshold value', () => {
 describe('change threshold value', () => {
-  it('should update value and resort rows', () => {
+  it('should not change threshold at index 0', () => {
+    const thresholds = [
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 2, value: 75, color: '#6ED0E0' },
+    ];
+    const instance = setup({ thresholds });
+
+    const mockEvent = { target: { value: 12 } };
+
+    instance.onChangeThresholdValue(mockEvent, thresholds[0]);
+
+    expect(instance.state.thresholds).toEqual(thresholds);
+  });
+
+  it('should update value', () => {
     const instance = setup();
     const instance = setup();
-    const mockThresholds = [
-      { index: 0, value: 50, color: 'rgba(237, 129, 40, 0.89)' },
-      { index: 1, value: 75, color: 'rgba(237, 129, 40, 0.89)' },
+    const thresholds = [
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+      { index: 1, value: 50, color: '#EAB839' },
+      { index: 2, value: 75, color: '#6ED0E0' },
     ];
     ];
 
 
     instance.state = {
     instance.state = {
-      baseColor: BasicGaugeColor.Green,
-      thresholds: mockThresholds,
+      thresholds,
     };
     };
 
 
     const mockEvent = { target: { value: 78 } };
     const mockEvent = { target: { value: 78 } };
 
 
-    instance.onChangeThresholdValue(mockEvent, mockThresholds[0]);
+    instance.onChangeThresholdValue(mockEvent, thresholds[1]);
+
+    expect(instance.state.thresholds).toEqual([
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+      { index: 1, value: 78, color: '#EAB839' },
+      { index: 2, value: 75, color: '#6ED0E0' },
+    ]);
+  });
+});
+
+describe('on blur threshold value', () => {
+  it('should resort rows and update indexes', () => {
+    const instance = setup();
+    const thresholds = [
+      { index: 0, value: -Infinity, color: '#7EB26D' },
+      { index: 1, value: 78, color: '#EAB839' },
+      { index: 2, value: 75, color: '#6ED0E0' },
+    ];
+
+    instance.state = {
+      thresholds,
+    };
+
+    instance.onBlur();
 
 
     expect(instance.state.thresholds).toEqual([
     expect(instance.state.thresholds).toEqual([
-      { index: 0, value: 78, color: 'rgba(237, 129, 40, 0.89)' },
-      { index: 1, value: 75, color: 'rgba(237, 129, 40, 0.89)' },
+      { index: 2, value: 78, color: '#EAB839' },
+      { index: 1, value: 75, color: '#6ED0E0' },
+      { index: 0, value: -Infinity, color: '#7EB26D' },
     ]);
     ]);
   });
   });
 });
 });

+ 89 - 105
packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx

@@ -1,9 +1,10 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
-import tinycolor, { ColorInput } from 'tinycolor2';
+// import tinycolor, { ColorInput } from 'tinycolor2';
 
 
-import { Threshold, BasicGaugeColor } from '../../types';
+import { Threshold } from '../../types';
 import { ColorPicker } from '../ColorPicker/ColorPicker';
 import { ColorPicker } from '../ColorPicker/ColorPicker';
 import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
 import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
+import { colors } from '../../utils';
 
 
 export interface Props {
 export interface Props {
   thresholds: Threshold[];
   thresholds: Threshold[];
@@ -12,50 +13,43 @@ export interface Props {
 
 
 interface State {
 interface State {
   thresholds: Threshold[];
   thresholds: Threshold[];
-  baseColor: string;
 }
 }
 
 
 export class ThresholdsEditor extends PureComponent<Props, State> {
 export class ThresholdsEditor extends PureComponent<Props, State> {
   constructor(props: Props) {
   constructor(props: Props) {
     super(props);
     super(props);
 
 
-    this.state = { thresholds: props.thresholds, baseColor: BasicGaugeColor.Green };
+    const thresholds: Threshold[] =
+      props.thresholds.length > 0 ? props.thresholds : [{ index: 0, value: -Infinity, color: colors[0] }];
+    this.state = { thresholds };
   }
   }
 
 
   onAddThreshold = (index: number) => {
   onAddThreshold = (index: number) => {
-    const maxValue = 100; // hardcoded for now before we add the base threshold
-    const minValue = 0; // hardcoded for now before we add the base threshold
     const { thresholds } = this.state;
     const { thresholds } = this.state;
+    const maxValue = 100;
+    const minValue = 0;
+
+    if (index === 0) {
+      return;
+    }
 
 
     const newThresholds = thresholds.map(threshold => {
     const newThresholds = thresholds.map(threshold => {
       if (threshold.index >= index) {
       if (threshold.index >= index) {
-        threshold = {
-          ...threshold,
-          index: threshold.index + 1,
-        };
+        const index = threshold.index + 1;
+        threshold = { ...threshold, index };
       }
       }
-
       return threshold;
       return threshold;
     });
     });
 
 
     // Setting value to a value between the previous thresholds
     // Setting value to a value between the previous thresholds
-    let value;
-
-    if (index === 0 && thresholds.length === 0) {
-      value = maxValue - (maxValue - minValue) / 2;
-    } else if (index === 0 && thresholds.length > 0) {
-      value = newThresholds[index + 1].value - (newThresholds[index + 1].value - minValue) / 2;
-    } else if (index > newThresholds[newThresholds.length - 1].index) {
-      value = maxValue - (maxValue - newThresholds[index - 1].value) / 2;
-    }
+    const beforeThreshold = newThresholds.filter(t => t.index === index - 1 && t.index !== 0)[0];
+    const afterThreshold = newThresholds.filter(t => t.index === index + 1 && t.index !== 0)[0];
+    const beforeThresholdValue = beforeThreshold !== undefined ? beforeThreshold.value : minValue;
+    const afterThresholdValue = afterThreshold !== undefined ? afterThreshold.value : maxValue;
+    const value = afterThresholdValue - (afterThresholdValue - beforeThresholdValue) / 2;
 
 
-    // Set a color that lies between the previous thresholds
-    let color;
-    if (index === 0 && thresholds.length === 0) {
-      color = tinycolor.mix(BasicGaugeColor.Green, BasicGaugeColor.Red, 50).toRgbString();
-    } else {
-      color = tinycolor.mix(thresholds[index - 1].color as ColorInput, BasicGaugeColor.Red, 50).toRgbString();
-    }
+    // Set a color
+    const color = colors.filter(c => newThresholds.some(t => t.color === c) === false)[0];
 
 
     this.setState(
     this.setState(
       {
       {
@@ -73,18 +67,40 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
   };
   };
 
 
   onRemoveThreshold = (threshold: Threshold) => {
   onRemoveThreshold = (threshold: Threshold) => {
+    if (threshold.index === 0) {
+      return;
+    }
+
     this.setState(
     this.setState(
-      prevState => ({ thresholds: prevState.thresholds.filter(t => t !== threshold) }),
+      prevState => {
+        const newThresholds = prevState.thresholds.map(t => {
+          if (t.index > threshold.index) {
+            const index = t.index - 1;
+            t = { ...t, index };
+          }
+          return t;
+        });
+
+        return {
+          thresholds: newThresholds.filter(t => t !== threshold),
+        };
+      },
       () => this.updateGauge()
       () => this.updateGauge()
     );
     );
   };
   };
 
 
   onChangeThresholdValue = (event: any, threshold: Threshold) => {
   onChangeThresholdValue = (event: any, threshold: Threshold) => {
+    if (threshold.index === 0) {
+      return;
+    }
+
     const { thresholds } = this.state;
     const { thresholds } = this.state;
+    const parsedValue = parseInt(event.target.value, 10);
+    const value = isNaN(parsedValue) ? null : parsedValue;
 
 
     const newThresholds = thresholds.map(t => {
     const newThresholds = thresholds.map(t => {
       if (t === threshold) {
       if (t === threshold) {
-        t = { ...t, value: event.target.value };
+        t = { ...t, value: value as number };
       }
       }
 
 
       return t;
       return t;
@@ -114,7 +130,14 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
 
 
   onChangeBaseColor = (color: string) => this.props.onChange(this.state.thresholds);
   onChangeBaseColor = (color: string) => this.props.onChange(this.state.thresholds);
   onBlur = () => {
   onBlur = () => {
-    this.setState(prevState => ({ thresholds: this.sortThresholds(prevState.thresholds) }));
+    this.setState(prevState => {
+      const sortThresholds = this.sortThresholds([...prevState.thresholds]);
+      let index = sortThresholds.length - 1;
+      sortThresholds.forEach(t => {
+        t.index = index--;
+      });
+      return { thresholds: sortThresholds };
+    });
 
 
     this.updateGauge();
     this.updateGauge();
   };
   };
@@ -129,92 +152,53 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
     });
     });
   };
   };
 
 
-  renderThresholds() {
-    const { thresholds } = this.state;
-
-    return thresholds.map((threshold, index) => {
-      return (
-        <div className="threshold-row" key={`${threshold.index}-${index}`}>
-          <div className="threshold-row-inner">
-            <div className="threshold-row-color">
-              {threshold.color && (
-                <div className="threshold-row-color-inner">
-                  <ColorPicker
-                    color={threshold.color}
-                    onChange={color => this.onChangeThresholdColor(threshold, color)}
-                  />
-                </div>
-              )}
-            </div>
-            <input
-              className="threshold-row-input"
-              type="text"
-              onChange={event => this.onChangeThresholdValue(event, threshold)}
-              value={threshold.value}
-              onBlur={this.onBlur}
-            />
-            <div onClick={() => this.onRemoveThreshold(threshold)} className="threshold-row-remove">
-              <i className="fa fa-times" />
+  renderInput = (threshold: Threshold) => {
+    const value = threshold.index === 0 ? 'Base' : threshold.value;
+    return (
+      <div className="thresholds-row-input-inner">
+        <span className="thresholds-row-input-inner-arrow" />
+        <div className="thresholds-row-input-inner-color">
+          {threshold.color && (
+            <div className="thresholds-row-input-inner-color-colorpicker">
+              <ColorPicker color={threshold.color} onChange={color => this.onChangeThresholdColor(threshold, color)} />
             </div>
             </div>
-          </div>
+          )}
         </div>
         </div>
-      );
-    });
-  }
-
-  renderIndicator() {
-    const { thresholds } = this.state;
-
-    return thresholds.map((t, i) => {
-      return (
-        <div key={`${t.value}-${i}`} className="indicator-section">
-          <div onClick={() => this.onAddThreshold(t.index + 1)} style={{ height: '50%', backgroundColor: t.color }} />
-          <div onClick={() => this.onAddThreshold(t.index)} style={{ height: '50%', backgroundColor: t.color }} />
+        <div className="thresholds-row-input-inner-value">
+          <input
+            type="text"
+            onChange={event => this.onChangeThresholdValue(event, threshold)}
+            value={value}
+            onBlur={this.onBlur}
+            readOnly={threshold.index === 0}
+          />
         </div>
         </div>
-      );
-    });
-  }
-
-  renderBaseIndicator() {
-    return (
-      <div className="indicator-section" style={{ height: '100%' }}>
-        <div
-          onClick={() => this.onAddThreshold(0)}
-          style={{ height: '100%', backgroundColor: BasicGaugeColor.Green }}
-        />
-      </div>
-    );
-  }
-
-  renderBase() {
-    const baseColor = BasicGaugeColor.Green;
-
-    return (
-      <div className="threshold-row threshold-row-base">
-        <div className="threshold-row-inner threshold-row-inner--base">
-          <div className="threshold-row-color">
-            <div className="threshold-row-color-inner">
-              <ColorPicker color={baseColor} onChange={color => this.onChangeBaseColor(color)} />
-            </div>
+        {threshold.index > 0 && (
+          <div className="thresholds-row-input-inner-remove" onClick={() => this.onRemoveThreshold(threshold)}>
+            <i className="fa fa-times" />
           </div>
           </div>
-          <div className="threshold-row-label">Base</div>
-        </div>
+        )}
       </div>
       </div>
     );
     );
-  }
+  };
 
 
   render() {
   render() {
+    const { thresholds } = this.state;
+
     return (
     return (
       <PanelOptionsGroup title="Thresholds">
       <PanelOptionsGroup title="Thresholds">
         <div className="thresholds">
         <div className="thresholds">
-          <div className="color-indicators">
-            {this.renderIndicator()}
-            {this.renderBaseIndicator()}
-          </div>
-          <div className="threshold-rows">
-            {this.renderThresholds()}
-            {this.renderBase()}
-          </div>
+          {thresholds.map((threshold, index) => {
+            return (
+              <div className="thresholds-row" key={`${threshold.index}-${index}`}>
+                <div className="thresholds-row-add-button" onClick={() => this.onAddThreshold(threshold.index + 1)}>
+                  <i className="fa fa-plus" />
+                </div>
+                <div className="thresholds-row-color-indicator" style={{ backgroundColor: threshold.color }} />
+                <div className="thresholds-row-input">{this.renderInput(threshold)}</div>
+              </div>
+            );
+          })}
         </div>
         </div>
       </PanelOptionsGroup>
       </PanelOptionsGroup>
     );
     );

+ 75 - 66
packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss

@@ -1,103 +1,112 @@
 .thresholds {
 .thresholds {
+  margin-bottom: 10px;
+}
+
+.thresholds-row {
   display: flex;
   display: flex;
+  flex-direction: row;
+  height: 70px;
 }
 }
 
 
-.threshold-rows {
-  margin-left: 5px;
+.thresholds-row:first-child > .thresholds-row-color-indicator {
+  border-top-left-radius: $border-radius;
+  border-top-right-radius: $border-radius;
+  overflow: hidden;
 }
 }
 
 
-.threshold-row {
+.thresholds-row:last-child > .thresholds-row-color-indicator {
+  border-bottom-left-radius: $border-radius;
+  border-bottom-right-radius: $border-radius;
+  overflow: hidden;
+}
+
+.thresholds-row-add-button {
+  align-self: center;
+  margin-right: 5px;
+  color: $green;
+  height: 24px;
+  width: 24px;
+  background-color: $green;
+  border-radius: 50%;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
-  margin-top: 3px;
-  padding: 5px;
+  justify-content: center;
+  cursor: pointer;
+}
 
 
-  &::before {
-    font-family: 'FontAwesome';
-    content: '\f0d9';
-    color: $input-label-border-color;
-  }
+.thresholds-row-add-button > i {
+  color: $white;
 }
 }
 
 
-.threshold-row-inner {
-  border: 1px solid $input-label-border-color;
-  border-radius: $border-radius;
-  display: flex;
-  overflow: hidden;
-  height: 37px;
+.thresholds-row-color-indicator {
+  width: 10px;
+}
 
 
-  &--base {
-    width: auto;
-  }
+.thresholds-row-input {
+  margin-top: 49px;
+  margin-left: 2px;
 }
 }
 
 
-.threshold-row-color {
-  width: 36px;
-  border-right: 1px solid $input-label-border-color;
+.thresholds-row-input-inner {
   display: flex;
   display: flex;
-  align-items: center;
   justify-content: center;
   justify-content: center;
-  background-color: $input-bg;
+  flex-direction: row;
+  height: 42px;
 }
 }
 
 
-.threshold-row-color-inner {
-  border-radius: 10px;
-  overflow: hidden;
-  display: flex;
-  align-items: center;
-  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
+.thresholds-row-input-inner > div {
+  border-left: 1px solid $input-label-border-color;
+  border-top: 1px solid $input-label-border-color;
+  border-bottom: 1px solid $input-label-border-color;
 }
 }
 
 
-.threshold-row-input {
-  padding: 8px 10px;
-  width: 150px;
+.thresholds-row-input-inner > *:nth-child(2) {
+  border-top-left-radius: $border-radius;
+  border-bottom-left-radius: $border-radius;
 }
 }
 
 
-.threshold-row-label {
-  background-color: $input-label-bg;
-  padding: 5px;
-  display: flex;
-  align-items: center;
+.thresholds-row-input-inner > *:last-child {
+  border-top-right-radius: $border-radius;
+  border-bottom-right-radius: $border-radius;
 }
 }
 
 
-.threshold-row-add-label {
-  align-items: center;
-  display: flex;
-  padding: 5px 8px;
+.thresholds-row-input-inner-arrow {
+  align-self: center;
+  width: 0;
+  height: 0;
+  border-top: 6px solid transparent;
+  border-bottom: 6px solid transparent;
+  border-right: 6px solid $input-label-border-color;
 }
 }
 
 
-.threshold-row-remove {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  height: 37px;
-  width: 37px;
-  cursor: pointer;
+.thresholds-row-input-inner-value > input {
+  height: 100%;
+  padding: 8px 10px;
+  width: 150px;
 }
 }
 
 
-.threshold-row-add {
-  border-right: $border-width solid $input-label-border-color;
+.thresholds-row-input-inner-color {
+  width: 42px;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
-  width: 36px;
-  background-color: $green;
+  background-color: $input-bg;
 }
 }
 
 
-.threshold-row-label {
-  border-top-left-radius: 0;
-  border-bottom-left-radius: 0;
+.thresholds-row-input-inner-color-colorpicker {
+  border-radius: 10px;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
 }
 }
 
 
-.indicator-section {
-  width: 100%;
-  height: 50px;
+.thresholds-row-input-inner-remove {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 42px;
+  width: 42px;
+  background-color: $input-label-border-color;
   cursor: pointer;
   cursor: pointer;
 }
 }
-
-.color-indicators {
-  width: 15px;
-  border-bottom-left-radius: $border-radius;
-  border-bottom-right-radius: $border-radius;
-  overflow: hidden;
-}

+ 23 - 23
public/app/plugins/panel/gauge/MappingRow.tsx → packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx

@@ -1,20 +1,22 @@
-import React, { PureComponent } from 'react';
-import { FormField, Label, MappingType, RangeMap, Select, ValueMap } from '@grafana/ui';
+import React, { ChangeEvent, PureComponent } from 'react';
 
 
-interface Props {
-  mapping: ValueMap | RangeMap;
-  updateMapping: (mapping) => void;
-  removeMapping: () => void;
+import { MappingType, ValueMapping } from '../../types';
+import { FormField, Label, Select } from '..';
+
+export interface Props {
+  valueMapping: ValueMapping;
+  updateValueMapping: (valueMapping: ValueMapping) => void;
+  removeValueMapping: () => void;
 }
 }
 
 
 interface State {
 interface State {
-  from: string;
+  from?: string;
   id: number;
   id: number;
   operator: string;
   operator: string;
   text: string;
   text: string;
-  to: string;
+  to?: string;
   type: MappingType;
   type: MappingType;
-  value: string;
+  value?: string;
 }
 }
 
 
 const mappingOptions = [
 const mappingOptions = [
@@ -23,36 +25,34 @@ const mappingOptions = [
 ];
 ];
 
 
 export default class MappingRow extends PureComponent<Props, State> {
 export default class MappingRow extends PureComponent<Props, State> {
-  constructor(props) {
+  constructor(props: Props) {
     super(props);
     super(props);
 
 
-    this.state = {
-      ...props.mapping,
-    };
+    this.state = { ...props.valueMapping };
   }
   }
 
 
-  onMappingValueChange = event => {
+  onMappingValueChange = (event: ChangeEvent<HTMLInputElement>) => {
     this.setState({ value: event.target.value });
     this.setState({ value: event.target.value });
   };
   };
 
 
-  onMappingFromChange = event => {
+  onMappingFromChange = (event: ChangeEvent<HTMLInputElement>) => {
     this.setState({ from: event.target.value });
     this.setState({ from: event.target.value });
   };
   };
 
 
-  onMappingToChange = event => {
+  onMappingToChange = (event: ChangeEvent<HTMLInputElement>) => {
     this.setState({ to: event.target.value });
     this.setState({ to: event.target.value });
   };
   };
 
 
-  onMappingTextChange = event => {
+  onMappingTextChange = (event: ChangeEvent<HTMLInputElement>) => {
     this.setState({ text: event.target.value });
     this.setState({ text: event.target.value });
   };
   };
 
 
-  onMappingTypeChange = mappingType => {
+  onMappingTypeChange = (mappingType: MappingType) => {
     this.setState({ type: mappingType });
     this.setState({ type: mappingType });
   };
   };
 
 
   updateMapping = () => {
   updateMapping = () => {
-    this.props.updateMapping({ ...this.state });
+    this.props.updateValueMapping({ ...this.state } as ValueMapping);
   };
   };
 
 
   renderRow() {
   renderRow() {
@@ -65,7 +65,7 @@ export default class MappingRow extends PureComponent<Props, State> {
             label="From"
             label="From"
             labelWidth={4}
             labelWidth={4}
             inputProps={{
             inputProps={{
-              onChange: event => this.onMappingFromChange(event),
+              onChange: (event: ChangeEvent<HTMLInputElement>) => this.onMappingFromChange(event),
               onBlur: () => this.updateMapping(),
               onBlur: () => this.updateMapping(),
               value: from,
               value: from,
             }}
             }}
@@ -76,7 +76,7 @@ export default class MappingRow extends PureComponent<Props, State> {
             labelWidth={4}
             labelWidth={4}
             inputProps={{
             inputProps={{
               onBlur: () => this.updateMapping,
               onBlur: () => this.updateMapping,
-              onChange: event => this.onMappingToChange(event),
+              onChange: (event: ChangeEvent<HTMLInputElement>) => this.onMappingToChange(event),
               value: to,
               value: to,
             }}
             }}
             inputWidth={8}
             inputWidth={8}
@@ -101,7 +101,7 @@ export default class MappingRow extends PureComponent<Props, State> {
           labelWidth={4}
           labelWidth={4}
           inputProps={{
           inputProps={{
             onBlur: () => this.updateMapping,
             onBlur: () => this.updateMapping,
-            onChange: event => this.onMappingValueChange(event),
+            onChange: (event: ChangeEvent<HTMLInputElement>) => this.onMappingValueChange(event),
             value: value,
             value: value,
           }}
           }}
           inputWidth={8}
           inputWidth={8}
@@ -137,7 +137,7 @@ export default class MappingRow extends PureComponent<Props, State> {
         </div>
         </div>
         {this.renderRow()}
         {this.renderRow()}
         <div className="gf-form">
         <div className="gf-form">
-          <button onClick={this.props.removeMapping} className="gf-form-label gf-form-label--btn">
+          <button onClick={this.props.removeValueMapping} className="gf-form-label gf-form-label--btn">
             <i className="fa fa-times" />
             <i className="fa fa-times" />
           </button>
           </button>
         </div>
         </div>

+ 14 - 16
public/app/plugins/panel/gauge/ValueMappings.test.tsx → packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx

@@ -1,27 +1,23 @@
 import React from 'react';
 import React from 'react';
 import { shallow } from 'enzyme';
 import { shallow } from 'enzyme';
-import { GaugeOptions, MappingType, PanelOptionsProps } from '@grafana/ui';
-import { defaultProps } from 'app/plugins/panel/gauge/GaugePanelOptions';
 
 
-import ValueMappings from './ValueMappings';
+import { ValueMappingsEditor, Props } from './ValueMappingsEditor';
+import { MappingType } from '../../types/panel';
 
 
 const setup = (propOverrides?: object) => {
 const setup = (propOverrides?: object) => {
-  const props: PanelOptionsProps<GaugeOptions> = {
+  const props: Props = {
     onChange: jest.fn(),
     onChange: jest.fn(),
-    options: {
-      ...defaultProps.options,
-      mappings: [
-        { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
-        { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
-      ],
-    },
+    valueMappings: [
+      { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
+      { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
+    ],
   };
   };
 
 
   Object.assign(props, propOverrides);
   Object.assign(props, propOverrides);
 
 
-  const wrapper = shallow(<ValueMappings {...props} />);
+  const wrapper = shallow(<ValueMappingsEditor {...props} />);
 
 
-  const instance = wrapper.instance() as ValueMappings;
+  const instance = wrapper.instance() as ValueMappingsEditor;
 
 
   return {
   return {
     instance,
     instance,
@@ -40,18 +36,20 @@ describe('Render', () => {
 describe('On remove mapping', () => {
 describe('On remove mapping', () => {
   it('Should remove mapping with id 0', () => {
   it('Should remove mapping with id 0', () => {
     const { instance } = setup();
     const { instance } = setup();
+
     instance.onRemoveMapping(1);
     instance.onRemoveMapping(1);
 
 
-    expect(instance.state.mappings).toEqual([
+    expect(instance.state.valueMappings).toEqual([
       { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
       { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
     ]);
     ]);
   });
   });
 
 
   it('should remove mapping with id 1', () => {
   it('should remove mapping with id 1', () => {
     const { instance } = setup();
     const { instance } = setup();
+
     instance.onRemoveMapping(2);
     instance.onRemoveMapping(2);
 
 
-    expect(instance.state.mappings).toEqual([
+    expect(instance.state.valueMappings).toEqual([
       { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
       { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
     ]);
     ]);
   });
   });
@@ -67,7 +65,7 @@ describe('Next id to add', () => {
   });
   });
 
 
   it('should default to 1', () => {
   it('should default to 1', () => {
-    const { instance } = setup({ options: { ...defaultProps.options } });
+    const { instance } = setup({ valueMappings: [] });
 
 
     expect(instance.state.nextIdToAdd).toEqual(1);
     expect(instance.state.nextIdToAdd).toEqual(1);
   });
   });

+ 29 - 23
public/app/plugins/panel/gauge/ValueMappings.tsx → packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx

@@ -1,33 +1,39 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
-import { GaugeOptions, PanelOptionsProps, MappingType, RangeMap, ValueMap, PanelOptionsGroup } from '@grafana/ui';
 
 
 import MappingRow from './MappingRow';
 import MappingRow from './MappingRow';
+import { MappingType, ValueMapping } from '../../types/panel';
+import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
+
+export interface Props {
+  valueMappings: ValueMapping[];
+  onChange: (valueMappings: ValueMapping[]) => void;
+}
 
 
 interface State {
 interface State {
-  mappings: Array<ValueMap | RangeMap>;
+  valueMappings: ValueMapping[];
   nextIdToAdd: number;
   nextIdToAdd: number;
 }
 }
 
 
-export default class ValueMappings extends PureComponent<PanelOptionsProps<GaugeOptions>, State> {
-  constructor(props) {
+export class ValueMappingsEditor extends PureComponent<Props, State> {
+  constructor(props: Props) {
     super(props);
     super(props);
 
 
-    const mappings = props.options.mappings;
+    const mappings = props.valueMappings;
 
 
     this.state = {
     this.state = {
-      mappings: mappings || [],
-      nextIdToAdd: mappings.length > 0 ? this.getMaxIdFromMappings(mappings) : 1,
+      valueMappings: mappings,
+      nextIdToAdd: mappings.length > 0 ? this.getMaxIdFromValueMappings(mappings) : 1,
     };
     };
   }
   }
 
 
-  getMaxIdFromMappings(mappings) {
+  getMaxIdFromValueMappings(mappings: ValueMapping[]) {
     return Math.max.apply(null, mappings.map(mapping => mapping.id).map(m => m)) + 1;
     return Math.max.apply(null, mappings.map(mapping => mapping.id).map(m => m)) + 1;
   }
   }
 
 
   addMapping = () =>
   addMapping = () =>
     this.setState(prevState => ({
     this.setState(prevState => ({
-      mappings: [
-        ...prevState.mappings,
+      valueMappings: [
+        ...prevState.valueMappings,
         {
         {
           id: prevState.nextIdToAdd,
           id: prevState.nextIdToAdd,
           operator: '',
           operator: '',
@@ -41,23 +47,23 @@ export default class ValueMappings extends PureComponent<PanelOptionsProps<Gauge
       nextIdToAdd: prevState.nextIdToAdd + 1,
       nextIdToAdd: prevState.nextIdToAdd + 1,
     }));
     }));
 
 
-  onRemoveMapping = id => {
+  onRemoveMapping = (id: number) => {
     this.setState(
     this.setState(
       prevState => ({
       prevState => ({
-        mappings: prevState.mappings.filter(m => {
+        valueMappings: prevState.valueMappings.filter(m => {
           return m.id !== id;
           return m.id !== id;
         }),
         }),
       }),
       }),
       () => {
       () => {
-        this.props.onChange({ ...this.props.options, mappings: this.state.mappings });
+        this.props.onChange(this.state.valueMappings);
       }
       }
     );
     );
   };
   };
 
 
-  updateGauge = mapping => {
+  updateGauge = (mapping: ValueMapping) => {
     this.setState(
     this.setState(
       prevState => ({
       prevState => ({
-        mappings: prevState.mappings.map(m => {
+        valueMappings: prevState.valueMappings.map(m => {
           if (m.id === mapping.id) {
           if (m.id === mapping.id) {
             return { ...mapping };
             return { ...mapping };
           }
           }
@@ -66,24 +72,24 @@ export default class ValueMappings extends PureComponent<PanelOptionsProps<Gauge
         }),
         }),
       }),
       }),
       () => {
       () => {
-        this.props.onChange({ ...this.props.options, mappings: this.state.mappings });
+        this.props.onChange(this.state.valueMappings);
       }
       }
     );
     );
   };
   };
 
 
   render() {
   render() {
-    const { mappings } = this.state;
+    const { valueMappings } = this.state;
 
 
     return (
     return (
       <PanelOptionsGroup title="Value Mappings">
       <PanelOptionsGroup title="Value Mappings">
         <div>
         <div>
-          {mappings.length > 0 &&
-            mappings.map((mapping, index) => (
+          {valueMappings.length > 0 &&
+            valueMappings.map((valueMapping, index) => (
               <MappingRow
               <MappingRow
-                key={`${mapping.text}-${index}`}
-                mapping={mapping}
-                updateMapping={this.updateGauge}
-                removeMapping={() => this.onRemoveMapping(mapping.id)}
+                key={`${valueMapping.text}-${index}`}
+                valueMapping={valueMapping}
+                updateValueMapping={this.updateGauge}
+                removeValueMapping={() => this.onRemoveMapping(valueMapping.id)}
               />
               />
             ))}
             ))}
         </div>
         </div>

+ 0 - 0
public/sass/components/_value-mappings.scss → packages/grafana-ui/src/components/ValueMappingsEditor/_ValueMappingsEditor.scss


+ 6 - 6
public/app/plugins/panel/gauge/__snapshots__/ValueMappings.test.tsx.snap → packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap

@@ -7,7 +7,9 @@ exports[`Render should render component 1`] = `
   <div>
   <div>
     <MappingRow
     <MappingRow
       key="Ok-0"
       key="Ok-0"
-      mapping={
+      removeValueMapping={[Function]}
+      updateValueMapping={[Function]}
+      valueMapping={
         Object {
         Object {
           "id": 1,
           "id": 1,
           "operator": "",
           "operator": "",
@@ -16,12 +18,12 @@ exports[`Render should render component 1`] = `
           "value": "20",
           "value": "20",
         }
         }
       }
       }
-      removeMapping={[Function]}
-      updateMapping={[Function]}
     />
     />
     <MappingRow
     <MappingRow
       key="Meh-1"
       key="Meh-1"
-      mapping={
+      removeValueMapping={[Function]}
+      updateValueMapping={[Function]}
+      valueMapping={
         Object {
         Object {
           "from": "21",
           "from": "21",
           "id": 2,
           "id": 2,
@@ -31,8 +33,6 @@ exports[`Render should render component 1`] = `
           "type": 2,
           "type": 2,
         }
         }
       }
       }
-      removeMapping={[Function]}
-      updateMapping={[Function]}
     />
     />
   </div>
   </div>
   <div
   <div

+ 1 - 0
packages/grafana-ui/src/components/index.scss

@@ -6,4 +6,5 @@
 @import 'PanelOptionsGroup/PanelOptionsGroup';
 @import 'PanelOptionsGroup/PanelOptionsGroup';
 @import 'PanelOptionsGrid/PanelOptionsGrid';
 @import 'PanelOptionsGrid/PanelOptionsGrid';
 @import 'ColorPicker/ColorPicker';
 @import 'ColorPicker/ColorPicker';
+@import 'ValueMappingsEditor/ValueMappingsEditor';
 @import "FormField/FormField";
 @import "FormField/FormField";

+ 1 - 0
packages/grafana-ui/src/components/index.ts

@@ -22,3 +22,4 @@ export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
 export { Graph } from './Graph/Graph';
 export { Graph } from './Graph/Graph';
 export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
 export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
 export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
 export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
+export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';

+ 0 - 16
packages/grafana-ui/src/types/gauge.ts

@@ -1,16 +0,0 @@
-import { RangeMap, Threshold, ValueMap } from './panel';
-
-export interface GaugeOptions {
-  baseColor: string;
-  decimals: number;
-  mappings: Array<RangeMap | ValueMap>;
-  maxValue: number;
-  minValue: number;
-  prefix: string;
-  showThresholdLabels: boolean;
-  showThresholdMarkers: boolean;
-  stat: string;
-  suffix: string;
-  thresholds: Threshold[];
-  unit: string;
-}

+ 0 - 1
packages/grafana-ui/src/types/index.ts

@@ -1,4 +1,3 @@
 export * from './series';
 export * from './series';
 export * from './time';
 export * from './time';
 export * from './panel';
 export * from './panel';
-export * from './gauge';

+ 5 - 0
packages/grafana-ui/src/types/panel.ts

@@ -1,6 +1,8 @@
 import { TimeSeries, LoadingState } from './series';
 import { TimeSeries, LoadingState } from './series';
 import { TimeRange } from './time';
 import { TimeRange } from './time';
 
 
+export type InterpolateFunction = (value: string, format?: string | Function) => string;
+
 export interface PanelProps<T = any> {
 export interface PanelProps<T = any> {
   timeSeries: TimeSeries[];
   timeSeries: TimeSeries[];
   timeRange: TimeRange;
   timeRange: TimeRange;
@@ -9,6 +11,7 @@ export interface PanelProps<T = any> {
   renderCounter: number;
   renderCounter: number;
   width: number;
   width: number;
   height: number;
   height: number;
+  onInterpolate: InterpolateFunction;
 }
 }
 
 
 export interface PanelOptionsProps<T = any> {
 export interface PanelOptionsProps<T = any> {
@@ -53,6 +56,8 @@ interface BaseMap {
   type: MappingType;
   type: MappingType;
 }
 }
 
 
+export type ValueMapping = ValueMap | RangeMap;
+
 export interface ValueMap extends BaseMap {
 export interface ValueMap extends BaseMap {
   value: string;
   value: string;
 }
 }

+ 5 - 2
pkg/components/imguploader/imguploader.go

@@ -6,7 +6,6 @@ import (
 	"regexp"
 	"regexp"
 
 
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/log"
-
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
 )
 )
 
 
@@ -21,6 +20,10 @@ func (NopImageUploader) Upload(ctx context.Context, path string) (string, error)
 	return "", nil
 	return "", nil
 }
 }
 
 
+var (
+	logger = log.New("imguploader")
+)
+
 func NewImageUploader() (ImageUploader, error) {
 func NewImageUploader() (ImageUploader, error) {
 
 
 	switch setting.ImageUploadProvider {
 	switch setting.ImageUploadProvider {
@@ -94,7 +97,7 @@ func NewImageUploader() (ImageUploader, error) {
 	}
 	}
 
 
 	if setting.ImageUploadProvider != "" {
 	if setting.ImageUploadProvider != "" {
-		log.Error2("The external image storage configuration is invalid", "unsupported provider", setting.ImageUploadProvider)
+		logger.Error("The external image storage configuration is invalid", "unsupported provider", setting.ImageUploadProvider)
 	}
 	}
 
 
 	return NopImageUploader{}, nil
 	return NopImageUploader{}, nil

+ 2 - 20
pkg/log/log.go

@@ -10,13 +10,11 @@ import (
 	"path/filepath"
 	"path/filepath"
 	"strings"
 	"strings"
 
 
-	"gopkg.in/ini.v1"
-
 	"github.com/go-stack/stack"
 	"github.com/go-stack/stack"
+	"github.com/grafana/grafana/pkg/util"
 	"github.com/inconshreveable/log15"
 	"github.com/inconshreveable/log15"
 	isatty "github.com/mattn/go-isatty"
 	isatty "github.com/mattn/go-isatty"
-
-	"github.com/grafana/grafana/pkg/util"
+	"gopkg.in/ini.v1"
 )
 )
 
 
 var Root log15.Logger
 var Root log15.Logger
@@ -58,10 +56,6 @@ func Debug(format string, v ...interface{}) {
 	Root.Debug(message)
 	Root.Debug(message)
 }
 }
 
 
-func Debug2(message string, v ...interface{}) {
-	Root.Debug(message, v...)
-}
-
 func Info(format string, v ...interface{}) {
 func Info(format string, v ...interface{}) {
 	var message string
 	var message string
 	if len(v) > 0 {
 	if len(v) > 0 {
@@ -73,10 +67,6 @@ func Info(format string, v ...interface{}) {
 	Root.Info(message)
 	Root.Info(message)
 }
 }
 
 
-func Info2(message string, v ...interface{}) {
-	Root.Info(message, v...)
-}
-
 func Warn(format string, v ...interface{}) {
 func Warn(format string, v ...interface{}) {
 	var message string
 	var message string
 	if len(v) > 0 {
 	if len(v) > 0 {
@@ -88,18 +78,10 @@ func Warn(format string, v ...interface{}) {
 	Root.Warn(message)
 	Root.Warn(message)
 }
 }
 
 
-func Warn2(message string, v ...interface{}) {
-	Root.Warn(message, v...)
-}
-
 func Error(skip int, format string, v ...interface{}) {
 func Error(skip int, format string, v ...interface{}) {
 	Root.Error(fmt.Sprintf(format, v...))
 	Root.Error(fmt.Sprintf(format, v...))
 }
 }
 
 
-func Error2(message string, v ...interface{}) {
-	Root.Error(message, v...)
-}
-
 func Critical(skip int, format string, v ...interface{}) {
 func Critical(skip int, format string, v ...interface{}) {
 	Root.Crit(fmt.Sprintf(format, v...))
 	Root.Crit(fmt.Sprintf(format, v...))
 }
 }

+ 5 - 1
pkg/login/ext_user.go

@@ -11,6 +11,10 @@ func init() {
 	bus.AddHandler("auth", UpsertUser)
 	bus.AddHandler("auth", UpsertUser)
 }
 }
 
 
+var (
+	logger = log.New("login.ext_user")
+)
+
 func UpsertUser(cmd *m.UpsertUserCommand) error {
 func UpsertUser(cmd *m.UpsertUserCommand) error {
 	extUser := cmd.ExternalUser
 	extUser := cmd.ExternalUser
 
 
@@ -135,7 +139,7 @@ func updateUser(user *m.User, extUser *m.ExternalUserInfo) error {
 		return nil
 		return nil
 	}
 	}
 
 
-	log.Debug2("Syncing user info", "id", user.Id, "update", updateCmd)
+	logger.Debug("Syncing user info", "id", user.Id, "update", updateCmd)
 	return bus.Dispatch(updateCmd)
 	return bus.Dispatch(updateCmd)
 }
 }
 
 

+ 1 - 1
pkg/services/alerting/notifiers/telegram.go

@@ -130,7 +130,7 @@ func (this *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.Eval
 	defer func() {
 	defer func() {
 		err := imageFile.Close()
 		err := imageFile.Close()
 		if err != nil {
 		if err != nil {
-			log.Error2("Could not close Telegram inline image.", "err", err)
+			this.log.Error("Could not close Telegram inline image.", "err", err)
 		}
 		}
 	}()
 	}()
 
 

+ 5 - 2
pkg/services/alerting/test_notification.go

@@ -18,9 +18,12 @@ type NotificationTestCommand struct {
 	Settings *simplejson.Json
 	Settings *simplejson.Json
 }
 }
 
 
+var (
+	logger = log.New("alerting.testnotification")
+)
+
 func init() {
 func init() {
 	bus.AddHandler("alerting", handleNotificationTestCommand)
 	bus.AddHandler("alerting", handleNotificationTestCommand)
-
 }
 }
 
 
 func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
 func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
@@ -35,7 +38,7 @@ func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
 	notifiers, err := InitNotifier(model)
 	notifiers, err := InitNotifier(model)
 
 
 	if err != nil {
 	if err != nil {
-		log.Error2("Failed to create notifier", "error", err.Error())
+		logger.Error("Failed to create notifier", "error", err.Error())
 		return err
 		return err
 	}
 	}
 
 

+ 20 - 9
public/app/core/directives/dropdown_typeahead.ts

@@ -141,6 +141,9 @@ export function dropdownTypeahead2($compile) {
     link: ($scope, elem, attrs) => {
     link: ($scope, elem, attrs) => {
       const $input = $(inputTemplate);
       const $input = $(inputTemplate);
       const $button = $(buttonTemplate);
       const $button = $(buttonTemplate);
+      const timeoutId = {
+        blur: null
+      };
       $input.appendTo(elem);
       $input.appendTo(elem);
       $button.appendTo(elem);
       $button.appendTo(elem);
 
 
@@ -177,6 +180,14 @@ export function dropdownTypeahead2($compile) {
         []
         []
       );
       );
 
 
+      const closeDropdownMenu = () => {
+        $input.hide();
+        $input.val('');
+        $button.show();
+        $button.focus();
+        elem.removeClass('open');
+      };
+
       $scope.menuItemSelected = (index, subIndex) => {
       $scope.menuItemSelected = (index, subIndex) => {
         const menuItem = $scope.menuItems[index];
         const menuItem = $scope.menuItems[index];
         const payload: any = { $item: menuItem };
         const payload: any = { $item: menuItem };
@@ -184,6 +195,7 @@ export function dropdownTypeahead2($compile) {
           payload.$subItem = menuItem.submenu[subIndex];
           payload.$subItem = menuItem.submenu[subIndex];
         }
         }
         $scope.dropdownTypeaheadOnSelect(payload);
         $scope.dropdownTypeaheadOnSelect(payload);
+        closeDropdownMenu();
       };
       };
 
 
       $input.attr('data-provide', 'typeahead');
       $input.attr('data-provide', 'typeahead');
@@ -223,16 +235,15 @@ export function dropdownTypeahead2($compile) {
         elem.toggleClass('open', $input.val() === '');
         elem.toggleClass('open', $input.val() === '');
       });
       });
 
 
+      elem.mousedown((evt: Event) => {
+        evt.preventDefault();
+        timeoutId.blur = null;
+      });
+
       $input.blur(() => {
       $input.blur(() => {
-        $input.hide();
-        $input.val('');
-        $button.show();
-        $button.focus();
-        // clicking the function dropdown menu won't
-        // work if you remove class at once
-        setTimeout(() => {
-          elem.removeClass('open');
-        }, 200);
+        timeoutId.blur = setTimeout(() => {
+          closeDropdownMenu();
+        }, 1);
       });
       });
 
 
       $compile(elem.contents())($scope);
       $compile(elem.contents())($scope);

+ 22 - 50
public/app/core/utils/explore.test.ts

@@ -6,26 +6,13 @@ import {
   clearHistory,
   clearHistory,
   hasNonEmptyQuery,
   hasNonEmptyQuery,
 } from './explore';
 } from './explore';
-import { ExploreState } from 'app/types/explore';
+import { ExploreUrlState } from 'app/types/explore';
 import store from 'app/core/store';
 import store from 'app/core/store';
 
 
-const DEFAULT_EXPLORE_STATE: ExploreState = {
+const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
   datasource: null,
   datasource: null,
-  datasourceError: null,
-  datasourceLoading: null,
-  datasourceMissing: false,
-  exploreDatasources: [],
-  graphInterval: 1000,
-  history: [],
-  initialQueries: [],
-  queryTransactions: [],
+  queries: [],
   range: DEFAULT_RANGE,
   range: DEFAULT_RANGE,
-  showingGraph: true,
-  showingLogs: true,
-  showingTable: true,
-  supportsGraph: null,
-  supportsLogs: null,
-  supportsTable: null,
 };
 };
 
 
 describe('state functions', () => {
 describe('state functions', () => {
@@ -68,21 +55,19 @@ describe('state functions', () => {
     it('returns url parameter value for a state object', () => {
     it('returns url parameter value for a state object', () => {
       const state = {
       const state = {
         ...DEFAULT_EXPLORE_STATE,
         ...DEFAULT_EXPLORE_STATE,
-        initialDatasource: 'foo',
-        range: {
-          from: 'now-5h',
-          to: 'now',
-        },
-        initialQueries: [
+        datasource: 'foo',
+        queries: [
           {
           {
-            refId: '1',
             expr: 'metric{test="a/b"}',
             expr: 'metric{test="a/b"}',
           },
           },
           {
           {
-            refId: '2',
             expr: 'super{foo="x/z"}',
             expr: 'super{foo="x/z"}',
           },
           },
         ],
         ],
+        range: {
+          from: 'now-5h',
+          to: 'now',
+        },
       };
       };
       expect(serializeStateToUrlParam(state)).toBe(
       expect(serializeStateToUrlParam(state)).toBe(
         '{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' +
         '{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' +
@@ -93,21 +78,19 @@ describe('state functions', () => {
     it('returns url parameter value for a state object', () => {
     it('returns url parameter value for a state object', () => {
       const state = {
       const state = {
         ...DEFAULT_EXPLORE_STATE,
         ...DEFAULT_EXPLORE_STATE,
-        initialDatasource: 'foo',
-        range: {
-          from: 'now-5h',
-          to: 'now',
-        },
-        initialQueries: [
+        datasource: 'foo',
+        queries: [
           {
           {
-            refId: '1',
             expr: 'metric{test="a/b"}',
             expr: 'metric{test="a/b"}',
           },
           },
           {
           {
-            refId: '2',
             expr: 'super{foo="x/z"}',
             expr: 'super{foo="x/z"}',
           },
           },
         ],
         ],
+        range: {
+          from: 'now-5h',
+          to: 'now',
+        },
       };
       };
       expect(serializeStateToUrlParam(state, true)).toBe(
       expect(serializeStateToUrlParam(state, true)).toBe(
         '["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"}]'
         '["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"}]'
@@ -119,35 +102,24 @@ describe('state functions', () => {
     it('can parse the serialized state into the original state', () => {
     it('can parse the serialized state into the original state', () => {
       const state = {
       const state = {
         ...DEFAULT_EXPLORE_STATE,
         ...DEFAULT_EXPLORE_STATE,
-        initialDatasource: 'foo',
-        range: {
-          from: 'now - 5h',
-          to: 'now',
-        },
-        initialQueries: [
+        datasource: 'foo',
+        queries: [
           {
           {
-            refId: '1',
             expr: 'metric{test="a/b"}',
             expr: 'metric{test="a/b"}',
           },
           },
           {
           {
-            refId: '2',
             expr: 'super{foo="x/z"}',
             expr: 'super{foo="x/z"}',
           },
           },
         ],
         ],
+        range: {
+          from: 'now - 5h',
+          to: 'now',
+        },
       };
       };
       const serialized = serializeStateToUrlParam(state);
       const serialized = serializeStateToUrlParam(state);
       const parsed = parseUrlState(serialized);
       const parsed = parseUrlState(serialized);
 
 
-      // Account for datasource vs datasourceName
-      const { datasource, queries, ...rest } = parsed;
-      const resultState = {
-        ...rest,
-        datasource: DEFAULT_EXPLORE_STATE.datasource,
-        initialDatasource: datasource,
-        initialQueries: queries,
-      };
-
-      expect(state).toMatchObject(resultState);
+      expect(state).toMatchObject(parsed);
     });
     });
   });
   });
 });
 });

+ 85 - 22
public/app/core/utils/explore.ts

@@ -1,6 +1,7 @@
 import _ from 'lodash';
 import _ from 'lodash';
-import { colors } from '@grafana/ui';
+import { colors, RawTimeRange, IntervalValues } from '@grafana/ui';
 
 
+import * as dateMath from 'app/core/utils/datemath';
 import { renderUrl } from 'app/core/utils/url';
 import { renderUrl } from 'app/core/utils/url';
 import kbn from 'app/core/utils/kbn';
 import kbn from 'app/core/utils/kbn';
 import store from 'app/core/store';
 import store from 'app/core/store';
@@ -8,9 +9,15 @@ import { parse as parseDate } from 'app/core/utils/datemath';
 
 
 import TimeSeries from 'app/core/time_series2';
 import TimeSeries from 'app/core/time_series2';
 import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
 import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
-import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore';
-import { DataQuery, DataSourceApi } from 'app/types/series';
-import { RawTimeRange, IntervalValues } from '@grafana/ui';
+import {
+  ExploreUrlState,
+  HistoryItem,
+  QueryTransaction,
+  ResultType,
+  QueryIntervals,
+  QueryOptions,
+} from 'app/types/explore';
+import { DataQuery } from 'app/types/series';
 
 
 export const DEFAULT_RANGE = {
 export const DEFAULT_RANGE = {
   from: 'now-6h',
   from: 'now-6h',
@@ -19,6 +26,8 @@ export const DEFAULT_RANGE = {
 
 
 const MAX_HISTORY_ITEMS = 100;
 const MAX_HISTORY_ITEMS = 100;
 
 
+export const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
+
 /**
 /**
  * Returns an Explore-URL that contains a panel's queries and the dashboard time range.
  * Returns an Explore-URL that contains a panel's queries and the dashboard time range.
  *
  *
@@ -77,7 +86,63 @@ export async function getExploreUrl(
   return url;
   return url;
 }
 }
 
 
-const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
+export function buildQueryTransaction(
+  query: DataQuery,
+  rowIndex: number,
+  resultType: ResultType,
+  queryOptions: QueryOptions,
+  range: RawTimeRange,
+  queryIntervals: QueryIntervals,
+  scanning: boolean
+): QueryTransaction {
+  const { interval, intervalMs } = queryIntervals;
+
+  const configuredQueries = [
+    {
+      ...query,
+      ...queryOptions,
+    },
+  ];
+
+  // Clone range for query request
+  // const queryRange: RawTimeRange = { ...range };
+  // const { from, to, raw } = this.timeSrv.timeRange();
+  // Most datasource is using `panelId + query.refId` for cancellation logic.
+  // Using `format` here because it relates to the view panel that the request is for.
+  // However, some datasources don't use `panelId + query.refId`, but only `panelId`.
+  // Therefore panel id has to be unique.
+  const panelId = `${queryOptions.format}-${query.key}`;
+
+  const options = {
+    interval,
+    intervalMs,
+    panelId,
+    targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key.
+    range: {
+      from: dateMath.parse(range.from, false),
+      to: dateMath.parse(range.to, true),
+      raw: range,
+    },
+    rangeRaw: range,
+    scopedVars: {
+      __interval: { text: interval, value: interval },
+      __interval_ms: { text: intervalMs, value: intervalMs },
+    },
+  };
+
+  return {
+    options,
+    query,
+    resultType,
+    rowIndex,
+    scanning,
+    id: generateKey(), // reusing for unique ID
+    done: false,
+    latency: 0,
+  };
+}
+
+export const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
 
 
 export function parseUrlState(initial: string | undefined): ExploreUrlState {
 export function parseUrlState(initial: string | undefined): ExploreUrlState {
   if (initial) {
   if (initial) {
@@ -103,12 +168,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
   return { datasource: null, queries: [], range: DEFAULT_RANGE };
   return { datasource: null, queries: [], range: DEFAULT_RANGE };
 }
 }
 
 
-export function serializeStateToUrlParam(state: ExploreState, compact?: boolean): string {
-  const urlState: ExploreUrlState = {
-    datasource: state.initialDatasource,
-    queries: state.initialQueries.map(clearQueryKeys),
-    range: state.range,
-  };
+export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string {
   if (compact) {
   if (compact) {
     return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]);
     return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]);
   }
   }
@@ -123,7 +183,7 @@ export function generateRefId(index = 0): string {
   return `${index + 1}`;
   return `${index + 1}`;
 }
 }
 
 
-export function generateQueryKeys(index = 0): { refId: string; key: string } {
+export function generateEmptyQuery(index = 0): { refId: string; key: string } {
   return { refId: generateRefId(index), key: generateKey(index) };
   return { refId: generateRefId(index), key: generateKey(index) };
 }
 }
 
 
@@ -132,20 +192,23 @@ export function generateQueryKeys(index = 0): { refId: string; key: string } {
  */
  */
 export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
 export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
   if (queries && typeof queries === 'object' && queries.length > 0) {
   if (queries && typeof queries === 'object' && queries.length > 0) {
-    return queries.map((query, i) => ({ ...query, ...generateQueryKeys(i) }));
+    return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(i) }));
   }
   }
-  return [{ ...generateQueryKeys() }];
+  return [{ ...generateEmptyQuery() }];
 }
 }
 
 
 /**
 /**
  * A target is non-empty when it has keys (with non-empty values) other than refId and key.
  * A target is non-empty when it has keys (with non-empty values) other than refId and key.
  */
  */
 export function hasNonEmptyQuery(queries: DataQuery[]): boolean {
 export function hasNonEmptyQuery(queries: DataQuery[]): boolean {
-  return queries.some(
-    query =>
-      Object.keys(query)
-        .map(k => query[k])
-        .filter(v => v).length > 2
+  return (
+    queries &&
+    queries.some(
+      query =>
+        Object.keys(query)
+          .map(k => query[k])
+          .filter(v => v).length > 2
+    )
   );
   );
 }
 }
 
 
@@ -180,8 +243,8 @@ export function calculateResultsFromQueryTransactions(
   };
   };
 }
 }
 
 
-export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, resolution: number): IntervalValues {
-  if (!datasource || !resolution) {
+export function getIntervals(range: RawTimeRange, lowLimit: string, resolution: number): IntervalValues {
+  if (!resolution) {
     return { interval: '1s', intervalMs: 1000 };
     return { interval: '1s', intervalMs: 1000 };
   }
   }
 
 
@@ -190,7 +253,7 @@ export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, res
     to: parseDate(range.to, true),
     to: parseDate(range.to, true),
   };
   };
 
 
-  return kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
+  return kbn.calculateInterval(absoluteRange, resolution, lowLimit);
 }
 }
 
 
 export function makeTimeSeriesList(dataList) {
 export function makeTimeSeriesList(dataList) {

+ 7 - 1
public/app/features/dashboard/dashgrid/PanelChrome.tsx

@@ -20,6 +20,7 @@ import { PanelPlugin } from 'app/types';
 import { TimeRange } from '@grafana/ui';
 import { TimeRange } from '@grafana/ui';
 
 
 import variables from 'sass/_variables.scss';
 import variables from 'sass/_variables.scss';
+import templateSrv from 'app/features/templating/template_srv';
 
 
 export interface Props {
 export interface Props {
   panel: PanelModel;
   panel: PanelModel;
@@ -78,6 +79,10 @@ export class PanelChrome extends PureComponent<Props, State> {
     });
     });
   };
   };
 
 
+  onInterpolate = (value: string, format?: string) => {
+    return templateSrv.replace(value, this.props.panel.scopedVars, format);
+  };
+
   get isVisible() {
   get isVisible() {
     return !this.props.dashboard.otherPanelInFullscreen(this.props.panel);
     return !this.props.dashboard.otherPanelInFullscreen(this.props.panel);
   }
   }
@@ -124,9 +129,10 @@ export class PanelChrome extends PureComponent<Props, State> {
                         timeSeries={timeSeries}
                         timeSeries={timeSeries}
                         timeRange={timeRange}
                         timeRange={timeRange}
                         options={panel.getOptions(plugin.exports.PanelDefaults)}
                         options={panel.getOptions(plugin.exports.PanelDefaults)}
-                        width={width - 2 * variables.panelHorizontalPadding }
+                        width={width - 2 * variables.panelHorizontalPadding}
                         height={height - PANEL_HEADER_HEIGHT - variables.panelVerticalPadding}
                         height={height - PANEL_HEADER_HEIGHT - variables.panelVerticalPadding}
                         renderCounter={renderCounter}
                         renderCounter={renderCounter}
+                        onInterpolate={this.onInterpolate}
                       />
                       />
                     </div>
                     </div>
                   );
                   );

+ 5 - 2
public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx

@@ -3,6 +3,7 @@ import classNames from 'classnames';
 
 
 import PanelHeaderCorner from './PanelHeaderCorner';
 import PanelHeaderCorner from './PanelHeaderCorner';
 import { PanelHeaderMenu } from './PanelHeaderMenu';
 import { PanelHeaderMenu } from './PanelHeaderMenu';
+import templateSrv from 'app/features/templating/template_srv';
 
 
 import { DashboardModel } from 'app/features/dashboard/dashboard_model';
 import { DashboardModel } from 'app/features/dashboard/dashboard_model';
 import { PanelModel } from 'app/features/dashboard/panel_model';
 import { PanelModel } from 'app/features/dashboard/panel_model';
@@ -45,7 +46,9 @@ export class PanelHeader extends Component<Props, State> {
     const isFullscreen = false;
     const isFullscreen = false;
     const isLoading = false;
     const isLoading = false;
     const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
     const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
-    const { panel, dashboard, timeInfo } = this.props;
+    const { panel, dashboard, timeInfo, scopedVars } = this.props;
+    const title = templateSrv.replaceWithText(panel.title, scopedVars);
+
     return (
     return (
       <>
       <>
         <PanelHeaderCorner
         <PanelHeaderCorner
@@ -65,7 +68,7 @@ export class PanelHeader extends Component<Props, State> {
             <div className="panel-title">
             <div className="panel-title">
               <span className="icon-gf panel-alert-icon" />
               <span className="icon-gf panel-alert-icon" />
               <span className="panel-title-text">
               <span className="panel-title-text">
-                {panel.title} <span className="fa fa-caret-down panel-menu-toggle" />
+                {title} <span className="fa fa-caret-down panel-menu-toggle" />
               </span>
               </span>
 
 
               {this.state.panelMenuOpen && (
               {this.state.panelMenuOpen && (

File diff suppressed because it is too large
+ 113 - 788
public/app/features/explore/Explore.tsx


+ 61 - 0
public/app/features/explore/GraphContainer.tsx

@@ -0,0 +1,61 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import { RawTimeRange, TimeRange } from '@grafana/ui';
+
+import { ExploreId, ExploreItemState } from 'app/types/explore';
+import { StoreState } from 'app/types';
+
+import { toggleGraph } from './state/actions';
+import Graph from './Graph';
+import Panel from './Panel';
+
+interface GraphContainerProps {
+  onChangeTime: (range: TimeRange) => void;
+  exploreId: ExploreId;
+  graphResult?: any[];
+  loading: boolean;
+  range: RawTimeRange;
+  showingGraph: boolean;
+  showingTable: boolean;
+  split: boolean;
+  toggleGraph: typeof toggleGraph;
+}
+
+export class GraphContainer extends PureComponent<GraphContainerProps> {
+  onClickGraphButton = () => {
+    this.props.toggleGraph(this.props.exploreId);
+  };
+
+  render() {
+    const { exploreId, graphResult, loading, onChangeTime, showingGraph, showingTable, range, split } = this.props;
+    const graphHeight = showingGraph && showingTable ? '200px' : '400px';
+    return (
+      <Panel label="Graph" isOpen={showingGraph} loading={loading} onToggle={this.onClickGraphButton}>
+        <Graph
+          data={graphResult}
+          height={graphHeight}
+          id={`explore-graph-${exploreId}`}
+          onChangeTime={onChangeTime}
+          range={range}
+          split={split}
+        />
+      </Panel>
+    );
+  }
+}
+
+function mapStateToProps(state: StoreState, { exploreId }) {
+  const explore = state.explore;
+  const { split } = explore;
+  const item: ExploreItemState = explore[exploreId];
+  const { graphResult, queryTransactions, range, showingGraph, showingTable } = item;
+  const loading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done);
+  return { graphResult, loading, range, showingGraph, showingTable, split };
+}
+
+const mapDispatchToProps = {
+  toggleGraph,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(GraphContainer));

+ 3 - 3
public/app/features/explore/Logs.tsx

@@ -241,9 +241,9 @@ function renderMetaItem(value: any, kind: LogsMetaKind) {
 
 
 interface LogsProps {
 interface LogsProps {
   data: LogsModel;
   data: LogsModel;
+  exploreId: string;
   highlighterExpressions: string[];
   highlighterExpressions: string[];
   loading: boolean;
   loading: boolean;
-  position: string;
   range?: RawTimeRange;
   range?: RawTimeRange;
   scanning?: boolean;
   scanning?: boolean;
   scanRange?: RawTimeRange;
   scanRange?: RawTimeRange;
@@ -348,10 +348,10 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
   render() {
   render() {
     const {
     const {
       data,
       data,
+      exploreId,
       highlighterExpressions,
       highlighterExpressions,
       loading = false,
       loading = false,
       onClickLabel,
       onClickLabel,
-      position,
       range,
       range,
       scanning,
       scanning,
       scanRange,
       scanRange,
@@ -400,7 +400,7 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
             data={data.series}
             data={data.series}
             height="100px"
             height="100px"
             range={range}
             range={range}
-            id={`explore-logs-graph-${position}`}
+            id={`explore-logs-graph-${exploreId}`}
             onChangeTime={this.props.onChangeTime}
             onChangeTime={this.props.onChangeTime}
             onToggleSeries={this.onToggleLogLevel}
             onToggleSeries={this.onToggleLogLevel}
             userOptions={graphOptions}
             userOptions={graphOptions}

+ 91 - 0
public/app/features/explore/LogsContainer.tsx

@@ -0,0 +1,91 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import { RawTimeRange, TimeRange } from '@grafana/ui';
+
+import { ExploreId, ExploreItemState } from 'app/types/explore';
+import { LogsModel } from 'app/core/logs_model';
+import { StoreState } from 'app/types';
+
+import { toggleLogs } from './state/actions';
+import Logs from './Logs';
+import Panel from './Panel';
+
+interface LogsContainerProps {
+  exploreId: ExploreId;
+  loading: boolean;
+  logsHighlighterExpressions?: string[];
+  logsResult?: LogsModel;
+  onChangeTime: (range: TimeRange) => void;
+  onClickLabel: (key: string, value: string) => void;
+  onStartScanning: () => void;
+  onStopScanning: () => void;
+  range: RawTimeRange;
+  scanning?: boolean;
+  scanRange?: RawTimeRange;
+  showingLogs: boolean;
+  toggleLogs: typeof toggleLogs;
+}
+
+export class LogsContainer extends PureComponent<LogsContainerProps> {
+  onClickLogsButton = () => {
+    this.props.toggleLogs(this.props.exploreId);
+  };
+
+  render() {
+    const {
+      exploreId,
+      loading,
+      logsHighlighterExpressions,
+      logsResult,
+      onChangeTime,
+      onClickLabel,
+      onStartScanning,
+      onStopScanning,
+      range,
+      showingLogs,
+      scanning,
+      scanRange,
+    } = this.props;
+    return (
+      <Panel label="Logs" loading={loading} isOpen={showingLogs} onToggle={this.onClickLogsButton}>
+        <Logs
+          data={logsResult}
+          exploreId={exploreId}
+          key={logsResult.id}
+          highlighterExpressions={logsHighlighterExpressions}
+          loading={loading}
+          onChangeTime={onChangeTime}
+          onClickLabel={onClickLabel}
+          onStartScanning={onStartScanning}
+          onStopScanning={onStopScanning}
+          range={range}
+          scanning={scanning}
+          scanRange={scanRange}
+        />
+      </Panel>
+    );
+  }
+}
+
+function mapStateToProps(state: StoreState, { exploreId }) {
+  const explore = state.explore;
+  const item: ExploreItemState = explore[exploreId];
+  const { logsHighlighterExpressions, logsResult, queryTransactions, scanning, scanRange, showingLogs, range } = item;
+  const loading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done);
+  return {
+    loading,
+    logsHighlighterExpressions,
+    logsResult,
+    scanning,
+    scanRange,
+    showingLogs,
+    range,
+  };
+}
+
+const mapDispatchToProps = {
+  toggleLogs,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(LogsContainer));

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

@@ -48,7 +48,7 @@ export default class QueryEditor extends PureComponent<QueryEditorProps, any> {
           getNextQueryLetter: x => '',
           getNextQueryLetter: x => '',
         },
         },
         hideEditorRowActions: true,
         hideEditorRowActions: true,
-        ...getIntervals(range, datasource, null), // Possible to get resolution?
+        ...getIntervals(range, (datasource || {}).interval, null), // Possible to get resolution?
       },
       },
     };
     };
 
 

+ 163 - 0
public/app/features/explore/QueryRow.tsx

@@ -0,0 +1,163 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import { RawTimeRange } from '@grafana/ui';
+import _ from 'lodash';
+
+import { QueryTransaction, HistoryItem, QueryHint, ExploreItemState, ExploreId } from 'app/types/explore';
+import { Emitter } from 'app/core/utils/emitter';
+import { DataQuery, StoreState } from 'app/types';
+
+// import DefaultQueryField from './QueryField';
+import QueryEditor from './QueryEditor';
+import QueryTransactionStatus from './QueryTransactionStatus';
+import {
+  addQueryRow,
+  changeQuery,
+  highlightLogsExpression,
+  modifyQueries,
+  removeQueryRow,
+  runQueries,
+} from './state/actions';
+
+function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
+  const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
+  if (transaction) {
+    return transaction.hints[0];
+  }
+  return undefined;
+}
+
+interface QueryRowProps {
+  addQueryRow: typeof addQueryRow;
+  changeQuery: typeof changeQuery;
+  className?: string;
+  exploreId: ExploreId;
+  datasourceInstance: any;
+  highlightLogsExpression: typeof highlightLogsExpression;
+  history: HistoryItem[];
+  index: number;
+  initialQuery: DataQuery;
+  modifyQueries: typeof modifyQueries;
+  queryTransactions: QueryTransaction[];
+  exploreEvents: Emitter;
+  range: RawTimeRange;
+  removeQueryRow: typeof removeQueryRow;
+  runQueries: typeof runQueries;
+}
+
+export class QueryRow extends PureComponent<QueryRowProps> {
+  onExecuteQuery = () => {
+    const { exploreId } = this.props;
+    this.props.runQueries(exploreId);
+  };
+
+  onChangeQuery = (query: DataQuery, override?: boolean) => {
+    const { datasourceInstance, exploreId, index } = this.props;
+    this.props.changeQuery(exploreId, query, index, override);
+    if (query && !override && datasourceInstance.getHighlighterExpression && index === 0) {
+      // Live preview of log search matches. Only use on first row for now
+      this.updateLogsHighlights(query);
+    }
+  };
+
+  onClickAddButton = () => {
+    const { exploreId, index } = this.props;
+    this.props.addQueryRow(exploreId, index);
+  };
+
+  onClickClearButton = () => {
+    this.onChangeQuery(null, true);
+  };
+
+  onClickHintFix = action => {
+    const { datasourceInstance, exploreId, index } = this.props;
+    if (datasourceInstance && datasourceInstance.modifyQuery) {
+      const modifier = (queries: DataQuery, action: any) => datasourceInstance.modifyQuery(queries, action);
+      this.props.modifyQueries(exploreId, action, index, modifier);
+    }
+  };
+
+  onClickRemoveButton = () => {
+    const { exploreId, index } = this.props;
+    this.props.removeQueryRow(exploreId, index);
+  };
+
+  updateLogsHighlights = _.debounce((value: DataQuery) => {
+    const { datasourceInstance } = this.props;
+    if (datasourceInstance.getHighlighterExpression) {
+      const expressions = [datasourceInstance.getHighlighterExpression(value)];
+      this.props.highlightLogsExpression(this.props.exploreId, expressions);
+    }
+  }, 500);
+
+  render() {
+    const { datasourceInstance, history, index, initialQuery, queryTransactions, exploreEvents, range } = this.props;
+    const transactions = queryTransactions.filter(t => t.rowIndex === index);
+    const transactionWithError = transactions.find(t => t.error !== undefined);
+    const hint = getFirstHintFromTransactions(transactions);
+    const queryError = transactionWithError ? transactionWithError.error : null;
+    const QueryField = datasourceInstance.pluginExports.ExploreQueryField;
+    return (
+      <div className="query-row">
+        <div className="query-row-status">
+          <QueryTransactionStatus transactions={transactions} />
+        </div>
+        <div className="query-row-field">
+          {QueryField ? (
+            <QueryField
+              datasource={datasourceInstance}
+              error={queryError}
+              hint={hint}
+              initialQuery={initialQuery}
+              history={history}
+              onClickHintFix={this.onClickHintFix}
+              onPressEnter={this.onExecuteQuery}
+              onQueryChange={this.onChangeQuery}
+            />
+          ) : (
+            <QueryEditor
+              datasource={datasourceInstance}
+              error={queryError}
+              onQueryChange={this.onChangeQuery}
+              onExecuteQuery={this.onExecuteQuery}
+              initialQuery={initialQuery}
+              exploreEvents={exploreEvents}
+              range={range}
+            />
+          )}
+        </div>
+        <div className="query-row-tools">
+          <button className="btn navbar-button navbar-button--tight" onClick={this.onClickClearButton}>
+            <i className="fa fa-times" />
+          </button>
+          <button className="btn navbar-button navbar-button--tight" onClick={this.onClickAddButton}>
+            <i className="fa fa-plus" />
+          </button>
+          <button className="btn navbar-button navbar-button--tight" onClick={this.onClickRemoveButton}>
+            <i className="fa fa-minus" />
+          </button>
+        </div>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state: StoreState, { exploreId, index }) {
+  const explore = state.explore;
+  const item: ExploreItemState = explore[exploreId];
+  const { datasourceInstance, history, initialQueries, queryTransactions, range } = item;
+  const initialQuery = initialQueries[index];
+  return { datasourceInstance, history, initialQuery, queryTransactions, range };
+}
+
+const mapDispatchToProps = {
+  addQueryRow,
+  changeQuery,
+  highlightLogsExpression,
+  modifyQueries,
+  removeQueryRow,
+  runQueries,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(QueryRow));

+ 9 - 143
public/app/features/explore/QueryRows.tsx

@@ -1,159 +1,25 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
 
 
-import { QueryTransaction, HistoryItem, QueryHint } from 'app/types/explore';
 import { Emitter } from 'app/core/utils/emitter';
 import { Emitter } from 'app/core/utils/emitter';
+import { DataQuery } from 'app/types';
+import { ExploreId } from 'app/types/explore';
 
 
-// import DefaultQueryField from './QueryField';
-import QueryEditor from './QueryEditor';
-import QueryTransactionStatus from './QueryTransactionStatus';
-import { DataSource, DataQuery } from 'app/types';
-import { RawTimeRange } from '@grafana/ui';
+import QueryRow from './QueryRow';
 
 
-function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
-  const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
-  if (transaction) {
-    return transaction.hints[0];
-  }
-  return undefined;
-}
-
-interface QueryRowEventHandlers {
-  onAddQueryRow: (index: number) => void;
-  onChangeQuery: (value: DataQuery, index: number, override?: boolean) => void;
-  onClickHintFix: (action: object, index?: number) => void;
-  onExecuteQuery: () => void;
-  onRemoveQueryRow: (index: number) => void;
-}
-
-interface QueryRowCommonProps {
+interface QueryRowsProps {
   className?: string;
   className?: string;
-  datasource: DataSource;
-  history: HistoryItem[];
-  transactions: QueryTransaction[];
   exploreEvents: Emitter;
   exploreEvents: Emitter;
-  range: RawTimeRange;
-}
-
-type QueryRowProps = QueryRowCommonProps &
-  QueryRowEventHandlers & {
-    index: number;
-    initialQuery: DataQuery;
-  };
-
-class QueryRow extends PureComponent<QueryRowProps> {
-  onExecuteQuery = () => {
-    const { onExecuteQuery } = this.props;
-    onExecuteQuery();
-  };
-
-  onChangeQuery = (value: DataQuery, override?: boolean) => {
-    const { index, onChangeQuery } = this.props;
-    if (onChangeQuery) {
-      onChangeQuery(value, index, override);
-    }
-  };
-
-  onClickAddButton = () => {
-    const { index, onAddQueryRow } = this.props;
-    if (onAddQueryRow) {
-      onAddQueryRow(index);
-    }
-  };
-
-  onClickClearButton = () => {
-    this.onChangeQuery(null, true);
-  };
-
-  onClickHintFix = action => {
-    const { index, onClickHintFix } = this.props;
-    if (onClickHintFix) {
-      onClickHintFix(action, index);
-    }
-  };
-
-  onClickRemoveButton = () => {
-    const { index, onRemoveQueryRow } = this.props;
-    if (onRemoveQueryRow) {
-      onRemoveQueryRow(index);
-    }
-  };
-
-  onPressEnter = () => {
-    const { onExecuteQuery } = this.props;
-    if (onExecuteQuery) {
-      onExecuteQuery();
-    }
-  };
-
-  render() {
-    const { datasource, history, initialQuery, transactions, exploreEvents, range } = this.props;
-    const transactionWithError = transactions.find(t => t.error !== undefined);
-    const hint = getFirstHintFromTransactions(transactions);
-    const queryError = transactionWithError ? transactionWithError.error : null;
-    const QueryField = datasource.pluginExports.ExploreQueryField;
-    return (
-      <div className="query-row">
-        <div className="query-row-status">
-          <QueryTransactionStatus transactions={transactions} />
-        </div>
-        <div className="query-row-field">
-          {QueryField ? (
-            <QueryField
-              datasource={datasource}
-              error={queryError}
-              hint={hint}
-              initialQuery={initialQuery}
-              history={history}
-              onClickHintFix={this.onClickHintFix}
-              onPressEnter={this.onPressEnter}
-              onQueryChange={this.onChangeQuery}
-            />
-          ) : (
-            <QueryEditor
-              datasource={datasource}
-              error={queryError}
-              onQueryChange={this.onChangeQuery}
-              onExecuteQuery={this.onExecuteQuery}
-              initialQuery={initialQuery}
-              exploreEvents={exploreEvents}
-              range={range}
-            />
-          )}
-        </div>
-        <div className="query-row-tools">
-          <button className="btn navbar-button navbar-button--tight" onClick={this.onClickClearButton}>
-            <i className="fa fa-times" />
-          </button>
-          <button className="btn navbar-button navbar-button--tight" onClick={this.onClickAddButton}>
-            <i className="fa fa-plus" />
-          </button>
-          <button className="btn navbar-button navbar-button--tight" onClick={this.onClickRemoveButton}>
-            <i className="fa fa-minus" />
-          </button>
-        </div>
-      </div>
-    );
-  }
+  exploreId: ExploreId;
+  initialQueries: DataQuery[];
 }
 }
-
-type QueryRowsProps = QueryRowCommonProps &
-  QueryRowEventHandlers & {
-    initialQueries: DataQuery[];
-  };
-
 export default class QueryRows extends PureComponent<QueryRowsProps> {
 export default class QueryRows extends PureComponent<QueryRowsProps> {
   render() {
   render() {
-    const { className = '', initialQueries, transactions, ...handlers } = this.props;
+    const { className = '', exploreEvents, exploreId, initialQueries } = this.props;
     return (
     return (
       <div className={className}>
       <div className={className}>
         {initialQueries.map((query, index) => (
         {initialQueries.map((query, index) => (
-          <QueryRow
-            key={query.key}
-            index={index}
-            initialQuery={query}
-            transactions={transactions.filter(t => t.rowIndex === index)}
-            {...handlers}
-          />
+          // TODO instead of relying on initialQueries, move to react key list in redux
+          <QueryRow key={query.key} exploreEvents={exploreEvents} exploreId={exploreId} index={index} />
         ))}
         ))}
       </div>
       </div>
     );
     );

+ 49 - 0
public/app/features/explore/TableContainer.tsx

@@ -0,0 +1,49 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+
+import { ExploreId, ExploreItemState } from 'app/types/explore';
+import { StoreState } from 'app/types';
+
+import { toggleGraph } from './state/actions';
+import Table from './Table';
+import Panel from './Panel';
+import TableModel from 'app/core/table_model';
+
+interface TableContainerProps {
+  exploreId: ExploreId;
+  loading: boolean;
+  onClickCell: (key: string, value: string) => void;
+  showingTable: boolean;
+  tableResult?: TableModel;
+  toggleGraph: typeof toggleGraph;
+}
+
+export class TableContainer extends PureComponent<TableContainerProps> {
+  onClickTableButton = () => {
+    this.props.toggleGraph(this.props.exploreId);
+  };
+
+  render() {
+    const { loading, onClickCell, showingTable, tableResult } = this.props;
+    return (
+      <Panel label="Table" loading={loading} isOpen={showingTable} onToggle={this.onClickTableButton}>
+        <Table data={tableResult} loading={loading} onClickCell={onClickCell} />
+      </Panel>
+    );
+  }
+}
+
+function mapStateToProps(state: StoreState, { exploreId }) {
+  const explore = state.explore;
+  const item: ExploreItemState = explore[exploreId];
+  const { queryTransactions, showingTable, tableResult } = item;
+  const loading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done);
+  return { loading, showingTable, tableResult };
+}
+
+const mapDispatchToProps = {
+  toggleGraph,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(TableContainer));

+ 31 - 63
public/app/features/explore/Wrapper.tsx

@@ -3,91 +3,56 @@ import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
 import { connect } from 'react-redux';
 
 
 import { updateLocation } from 'app/core/actions';
 import { updateLocation } from 'app/core/actions';
-import { serializeStateToUrlParam, parseUrlState } from 'app/core/utils/explore';
 import { StoreState } from 'app/types';
 import { StoreState } from 'app/types';
-import { ExploreState } from 'app/types/explore';
+import { ExploreId, ExploreUrlState } from 'app/types/explore';
+import { parseUrlState } from 'app/core/utils/explore';
 
 
+import { initializeExploreSplit } from './state/actions';
 import ErrorBoundary from './ErrorBoundary';
 import ErrorBoundary from './ErrorBoundary';
 import Explore from './Explore';
 import Explore from './Explore';
 
 
 interface WrapperProps {
 interface WrapperProps {
-  backendSrv?: any;
-  datasourceSrv?: any;
+  initializeExploreSplit: typeof initializeExploreSplit;
+  split: boolean;
   updateLocation: typeof updateLocation;
   updateLocation: typeof updateLocation;
   urlStates: { [key: string]: string };
   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 };
+export class Wrapper extends Component<WrapperProps> {
+  initialSplit: boolean;
+  urlStates: { [key: string]: ExploreUrlState };
 
 
   constructor(props: WrapperProps) {
   constructor(props: WrapperProps) {
     super(props);
     super(props);
-    this.urlStates = props.urlStates;
-    this.state = {
-      split: Boolean(props.urlStates[STATE_KEY_RIGHT]),
-      splitState: undefined,
-    };
+    this.urlStates = {};
+    const { left, right } = props.urlStates;
+    if (props.urlStates.left) {
+      this.urlStates.leftState = parseUrlState(left);
+    }
+    if (props.urlStates.right) {
+      this.urlStates.rightState = parseUrlState(right);
+      this.initialSplit = true;
+    }
   }
   }
 
 
-  onChangeSplit = (split: boolean, splitState: ExploreState) => {
-    this.setState({ split, splitState });
-    // When closing split, remove URL state for split part
-    if (!split) {
-      delete this.urlStates[STATE_KEY_RIGHT];
-      this.props.updateLocation({
-        query: this.urlStates,
-      });
+  componentDidMount() {
+    if (this.initialSplit) {
+      this.props.initializeExploreSplit();
     }
     }
-  };
-
-  onSaveState = (key: string, state: ExploreState) => {
-    const urlState = serializeStateToUrlParam(state, true);
-    this.urlStates[key] = urlState;
-    this.props.updateLocation({
-      query: this.urlStates,
-    });
-  };
+  }
 
 
   render() {
   render() {
-    const { datasourceSrv } = this.props;
-    // State overrides for props from first Explore
-    const { split, splitState } = this.state;
-    const urlStateLeft = parseUrlState(this.urlStates[STATE_KEY_LEFT]);
-    const urlStateRight = parseUrlState(this.urlStates[STATE_KEY_RIGHT]);
+    const { split } = this.props;
+    const { leftState, rightState } = this.urlStates;
 
 
     return (
     return (
       <div className="explore-wrapper">
       <div className="explore-wrapper">
         <ErrorBoundary>
         <ErrorBoundary>
-          <Explore
-            datasourceSrv={datasourceSrv}
-            onChangeSplit={this.onChangeSplit}
-            onSaveState={this.onSaveState}
-            position="left"
-            split={split}
-            stateKey={STATE_KEY_LEFT}
-            urlState={urlStateLeft}
-          />
+          <Explore exploreId={ExploreId.left} urlState={leftState} />
         </ErrorBoundary>
         </ErrorBoundary>
         {split && (
         {split && (
           <ErrorBoundary>
           <ErrorBoundary>
-            <Explore
-              datasourceSrv={datasourceSrv}
-              onChangeSplit={this.onChangeSplit}
-              onSaveState={this.onSaveState}
-              position="right"
-              split={split}
-              splitState={splitState}
-              stateKey={STATE_KEY_RIGHT}
-              urlState={urlStateRight}
-            />
+            <Explore exploreId={ExploreId.right} urlState={rightState} />
           </ErrorBoundary>
           </ErrorBoundary>
         )}
         )}
       </div>
       </div>
@@ -95,11 +60,14 @@ export class Wrapper extends Component<WrapperProps, WrapperState> {
   }
   }
 }
 }
 
 
-const mapStateToProps = (state: StoreState) => ({
-  urlStates: state.location.query,
-});
+const mapStateToProps = (state: StoreState) => {
+  const urlStates = state.location.query;
+  const { split } = state.explore;
+  return { split, urlStates };
+};
 
 
 const mapDispatchToProps = {
 const mapDispatchToProps = {
+  initializeExploreSplit,
   updateLocation,
   updateLocation,
 };
 };
 
 

+ 302 - 0
public/app/features/explore/state/actionTypes.ts

@@ -0,0 +1,302 @@
+import { RawTimeRange, TimeRange } from '@grafana/ui';
+
+import { Emitter } from 'app/core/core';
+import {
+  ExploreId,
+  ExploreItemState,
+  HistoryItem,
+  RangeScanner,
+  ResultType,
+  QueryTransaction,
+} from 'app/types/explore';
+import { DataSourceSelectItem } from 'app/types/datasources';
+import { DataQuery } from 'app/types';
+
+export enum ActionTypes {
+  AddQueryRow = 'explore/ADD_QUERY_ROW',
+  ChangeDatasource = 'explore/CHANGE_DATASOURCE',
+  ChangeQuery = 'explore/CHANGE_QUERY',
+  ChangeSize = 'explore/CHANGE_SIZE',
+  ChangeTime = 'explore/CHANGE_TIME',
+  ClearQueries = 'explore/CLEAR_QUERIES',
+  HighlightLogsExpression = 'explore/HIGHLIGHT_LOGS_EXPRESSION',
+  InitializeExplore = 'explore/INITIALIZE_EXPLORE',
+  InitializeExploreSplit = 'explore/INITIALIZE_EXPLORE_SPLIT',
+  LoadDatasourceFailure = 'explore/LOAD_DATASOURCE_FAILURE',
+  LoadDatasourceMissing = 'explore/LOAD_DATASOURCE_MISSING',
+  LoadDatasourcePending = 'explore/LOAD_DATASOURCE_PENDING',
+  LoadDatasourceSuccess = 'explore/LOAD_DATASOURCE_SUCCESS',
+  ModifyQueries = 'explore/MODIFY_QUERIES',
+  QueryTransactionFailure = 'explore/QUERY_TRANSACTION_FAILURE',
+  QueryTransactionStart = 'explore/QUERY_TRANSACTION_START',
+  QueryTransactionSuccess = 'explore/QUERY_TRANSACTION_SUCCESS',
+  RemoveQueryRow = 'explore/REMOVE_QUERY_ROW',
+  RunQueries = 'explore/RUN_QUERIES',
+  RunQueriesEmpty = 'explore/RUN_QUERIES_EMPTY',
+  ScanRange = 'explore/SCAN_RANGE',
+  ScanStart = 'explore/SCAN_START',
+  ScanStop = 'explore/SCAN_STOP',
+  SetQueries = 'explore/SET_QUERIES',
+  SplitClose = 'explore/SPLIT_CLOSE',
+  SplitOpen = 'explore/SPLIT_OPEN',
+  StateSave = 'explore/STATE_SAVE',
+  ToggleGraph = 'explore/TOGGLE_GRAPH',
+  ToggleLogs = 'explore/TOGGLE_LOGS',
+  ToggleTable = 'explore/TOGGLE_TABLE',
+}
+
+export interface AddQueryRowAction {
+  type: ActionTypes.AddQueryRow;
+  payload: {
+    exploreId: ExploreId;
+    index: number;
+    query: DataQuery;
+  };
+}
+
+export interface ChangeQueryAction {
+  type: ActionTypes.ChangeQuery;
+  payload: {
+    exploreId: ExploreId;
+    query: DataQuery;
+    index: number;
+    override: boolean;
+  };
+}
+
+export interface ChangeSizeAction {
+  type: ActionTypes.ChangeSize;
+  payload: {
+    exploreId: ExploreId;
+    width: number;
+    height: number;
+  };
+}
+
+export interface ChangeTimeAction {
+  type: ActionTypes.ChangeTime;
+  payload: {
+    exploreId: ExploreId;
+    range: TimeRange;
+  };
+}
+
+export interface ClearQueriesAction {
+  type: ActionTypes.ClearQueries;
+  payload: {
+    exploreId: ExploreId;
+  };
+}
+
+export interface HighlightLogsExpressionAction {
+  type: ActionTypes.HighlightLogsExpression;
+  payload: {
+    exploreId: ExploreId;
+    expressions: string[];
+  };
+}
+
+export interface InitializeExploreAction {
+  type: ActionTypes.InitializeExplore;
+  payload: {
+    exploreId: ExploreId;
+    containerWidth: number;
+    datasource: string;
+    eventBridge: Emitter;
+    exploreDatasources: DataSourceSelectItem[];
+    queries: DataQuery[];
+    range: RawTimeRange;
+  };
+}
+
+export interface InitializeExploreSplitAction {
+  type: ActionTypes.InitializeExploreSplit;
+}
+
+export interface LoadDatasourceFailureAction {
+  type: ActionTypes.LoadDatasourceFailure;
+  payload: {
+    exploreId: ExploreId;
+    error: string;
+  };
+}
+
+export interface LoadDatasourcePendingAction {
+  type: ActionTypes.LoadDatasourcePending;
+  payload: {
+    exploreId: ExploreId;
+    datasourceId: number;
+  };
+}
+
+export interface LoadDatasourceMissingAction {
+  type: ActionTypes.LoadDatasourceMissing;
+  payload: {
+    exploreId: ExploreId;
+  };
+}
+
+export interface LoadDatasourceSuccessAction {
+  type: ActionTypes.LoadDatasourceSuccess;
+  payload: {
+    exploreId: ExploreId;
+    StartPage?: any;
+    datasourceInstance: any;
+    history: HistoryItem[];
+    initialDatasource: string;
+    initialQueries: DataQuery[];
+    logsHighlighterExpressions?: any[];
+    showingStartPage: boolean;
+    supportsGraph: boolean;
+    supportsLogs: boolean;
+    supportsTable: boolean;
+  };
+}
+
+export interface ModifyQueriesAction {
+  type: ActionTypes.ModifyQueries;
+  payload: {
+    exploreId: ExploreId;
+    modification: any;
+    index: number;
+    modifier: (queries: DataQuery[], modification: any) => DataQuery[];
+  };
+}
+
+export interface QueryTransactionFailureAction {
+  type: ActionTypes.QueryTransactionFailure;
+  payload: {
+    exploreId: ExploreId;
+    queryTransactions: QueryTransaction[];
+  };
+}
+
+export interface QueryTransactionStartAction {
+  type: ActionTypes.QueryTransactionStart;
+  payload: {
+    exploreId: ExploreId;
+    resultType: ResultType;
+    rowIndex: number;
+    transaction: QueryTransaction;
+  };
+}
+
+export interface QueryTransactionSuccessAction {
+  type: ActionTypes.QueryTransactionSuccess;
+  payload: {
+    exploreId: ExploreId;
+    history: HistoryItem[];
+    queryTransactions: QueryTransaction[];
+  };
+}
+
+export interface RemoveQueryRowAction {
+  type: ActionTypes.RemoveQueryRow;
+  payload: {
+    exploreId: ExploreId;
+    index: number;
+  };
+}
+
+export interface RunQueriesEmptyAction {
+  type: ActionTypes.RunQueriesEmpty;
+  payload: {
+    exploreId: ExploreId;
+  };
+}
+
+export interface ScanStartAction {
+  type: ActionTypes.ScanStart;
+  payload: {
+    exploreId: ExploreId;
+    scanner: RangeScanner;
+  };
+}
+
+export interface ScanRangeAction {
+  type: ActionTypes.ScanRange;
+  payload: {
+    exploreId: ExploreId;
+    range: RawTimeRange;
+  };
+}
+
+export interface ScanStopAction {
+  type: ActionTypes.ScanStop;
+  payload: {
+    exploreId: ExploreId;
+  };
+}
+
+export interface SetQueriesAction {
+  type: ActionTypes.SetQueries;
+  payload: {
+    exploreId: ExploreId;
+    queries: DataQuery[];
+  };
+}
+
+export interface SplitCloseAction {
+  type: ActionTypes.SplitClose;
+}
+
+export interface SplitOpenAction {
+  type: ActionTypes.SplitOpen;
+  payload: {
+    itemState: ExploreItemState;
+  };
+}
+
+export interface StateSaveAction {
+  type: ActionTypes.StateSave;
+}
+
+export interface ToggleTableAction {
+  type: ActionTypes.ToggleTable;
+  payload: {
+    exploreId: ExploreId;
+  };
+}
+
+export interface ToggleGraphAction {
+  type: ActionTypes.ToggleGraph;
+  payload: {
+    exploreId: ExploreId;
+  };
+}
+
+export interface ToggleLogsAction {
+  type: ActionTypes.ToggleLogs;
+  payload: {
+    exploreId: ExploreId;
+  };
+}
+
+export type Action =
+  | AddQueryRowAction
+  | ChangeQueryAction
+  | ChangeSizeAction
+  | ChangeTimeAction
+  | ClearQueriesAction
+  | HighlightLogsExpressionAction
+  | InitializeExploreAction
+  | InitializeExploreSplitAction
+  | LoadDatasourceFailureAction
+  | LoadDatasourceMissingAction
+  | LoadDatasourcePendingAction
+  | LoadDatasourceSuccessAction
+  | ModifyQueriesAction
+  | QueryTransactionFailureAction
+  | QueryTransactionStartAction
+  | QueryTransactionSuccessAction
+  | RemoveQueryRowAction
+  | RunQueriesEmptyAction
+  | ScanRangeAction
+  | ScanStartAction
+  | ScanStopAction
+  | SetQueriesAction
+  | SplitCloseAction
+  | SplitOpenAction
+  | ToggleGraphAction
+  | ToggleLogsAction
+  | ToggleTableAction;

+ 757 - 0
public/app/features/explore/state/actions.ts

@@ -0,0 +1,757 @@
+import _ from 'lodash';
+import { ThunkAction } from 'redux-thunk';
+import { RawTimeRange, TimeRange } from '@grafana/ui';
+
+import {
+  LAST_USED_DATASOURCE_KEY,
+  clearQueryKeys,
+  ensureQueries,
+  generateEmptyQuery,
+  hasNonEmptyQuery,
+  makeTimeSeriesList,
+  updateHistory,
+  buildQueryTransaction,
+  serializeStateToUrlParam,
+} from 'app/core/utils/explore';
+
+import { updateLocation } from 'app/core/actions';
+import store from 'app/core/store';
+import { DataSourceSelectItem } from 'app/types/datasources';
+import { DataQuery, StoreState } from 'app/types';
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import {
+  ExploreId,
+  ExploreUrlState,
+  RangeScanner,
+  ResultType,
+  QueryOptions,
+  QueryTransaction,
+  QueryHint,
+  QueryHintGetter,
+} from 'app/types/explore';
+import { Emitter } from 'app/core/core';
+
+import {
+  Action as ThunkableAction,
+  ActionTypes,
+  AddQueryRowAction,
+  ChangeSizeAction,
+  HighlightLogsExpressionAction,
+  LoadDatasourceFailureAction,
+  LoadDatasourceMissingAction,
+  LoadDatasourcePendingAction,
+  LoadDatasourceSuccessAction,
+  QueryTransactionStartAction,
+  ScanStopAction,
+} from './actionTypes';
+
+type ThunkResult<R> = ThunkAction<R, StoreState, undefined, ThunkableAction>;
+
+/**
+ * Adds a query row after the row with the given index.
+ */
+export function addQueryRow(exploreId: ExploreId, index: number): AddQueryRowAction {
+  const query = generateEmptyQuery(index + 1);
+  return { type: ActionTypes.AddQueryRow, payload: { exploreId, index, query } };
+}
+
+/**
+ * Loads a new datasource identified by the given name.
+ */
+export function changeDatasource(exploreId: ExploreId, datasource: string): ThunkResult<void> {
+  return async dispatch => {
+    const instance = await getDatasourceSrv().get(datasource);
+    dispatch(loadDatasource(exploreId, instance));
+  };
+}
+
+/**
+ * Query change handler for the query row with the given index.
+ * If `override` is reset the query modifications and run the queries. Use this to set queries via a link.
+ */
+export function changeQuery(
+  exploreId: ExploreId,
+  query: DataQuery,
+  index: number,
+  override: boolean
+): ThunkResult<void> {
+  return dispatch => {
+    // Null query means reset
+    if (query === null) {
+      query = { ...generateEmptyQuery(index) };
+    }
+
+    dispatch({ type: ActionTypes.ChangeQuery, payload: { exploreId, query, index, override } });
+    if (override) {
+      dispatch(runQueries(exploreId));
+    }
+  };
+}
+
+/**
+ * Keep track of the Explore container size, in particular the width.
+ * The width will be used to calculate graph intervals (number of datapoints).
+ */
+export function changeSize(
+  exploreId: ExploreId,
+  { height, width }: { height: number; width: number }
+): ChangeSizeAction {
+  return { type: ActionTypes.ChangeSize, payload: { exploreId, height, width } };
+}
+
+/**
+ * Change the time range of Explore. Usually called from the Timepicker or a graph interaction.
+ */
+export function changeTime(exploreId: ExploreId, range: TimeRange): ThunkResult<void> {
+  return dispatch => {
+    dispatch({ type: ActionTypes.ChangeTime, payload: { exploreId, range } });
+    dispatch(runQueries(exploreId));
+  };
+}
+
+/**
+ * Clear all queries and results.
+ */
+export function clearQueries(exploreId: ExploreId): ThunkResult<void> {
+  return dispatch => {
+    dispatch(scanStop(exploreId));
+    dispatch({ type: ActionTypes.ClearQueries, payload: { exploreId } });
+    dispatch(stateSave());
+  };
+}
+
+/**
+ * Highlight expressions in the log results
+ */
+export function highlightLogsExpression(exploreId: ExploreId, expressions: string[]): HighlightLogsExpressionAction {
+  return { type: ActionTypes.HighlightLogsExpression, payload: { exploreId, expressions } };
+}
+
+/**
+ * Initialize Explore state with state from the URL and the React component.
+ * Call this only on components for with the Explore state has not been initialized.
+ */
+export function initializeExplore(
+  exploreId: ExploreId,
+  datasource: string,
+  queries: DataQuery[],
+  range: RawTimeRange,
+  containerWidth: number,
+  eventBridge: Emitter
+): ThunkResult<void> {
+  return async dispatch => {
+    const exploreDatasources: DataSourceSelectItem[] = getDatasourceSrv()
+      .getExternal()
+      .map(ds => ({
+        value: ds.name,
+        name: ds.name,
+        meta: ds.meta,
+      }));
+
+    dispatch({
+      type: ActionTypes.InitializeExplore,
+      payload: {
+        exploreId,
+        containerWidth,
+        datasource,
+        eventBridge,
+        exploreDatasources,
+        queries,
+        range,
+      },
+    });
+
+    if (exploreDatasources.length > 1) {
+      let instance;
+      if (datasource) {
+        instance = await getDatasourceSrv().get(datasource);
+      } else {
+        instance = await getDatasourceSrv().get();
+      }
+      dispatch(loadDatasource(exploreId, instance));
+    } else {
+      dispatch(loadDatasourceMissing(exploreId));
+    }
+  };
+}
+
+/**
+ * Initialize the wrapper split state
+ */
+export function initializeExploreSplit() {
+  return async dispatch => {
+    dispatch({ type: ActionTypes.InitializeExploreSplit });
+  };
+}
+
+/**
+ * Display an error that happened during the selection of a datasource
+ */
+export const loadDatasourceFailure = (exploreId: ExploreId, error: string): LoadDatasourceFailureAction => ({
+  type: ActionTypes.LoadDatasourceFailure,
+  payload: {
+    exploreId,
+    error,
+  },
+});
+
+/**
+ * Display an error when no datasources have been configured
+ */
+export const loadDatasourceMissing = (exploreId: ExploreId): LoadDatasourceMissingAction => ({
+  type: ActionTypes.LoadDatasourceMissing,
+  payload: { exploreId },
+});
+
+/**
+ * Start the async process of loading a datasource to display a loading indicator
+ */
+export const loadDatasourcePending = (exploreId: ExploreId, datasourceId: number): LoadDatasourcePendingAction => ({
+  type: ActionTypes.LoadDatasourcePending,
+  payload: {
+    exploreId,
+    datasourceId,
+  },
+});
+
+/**
+ * Datasource loading was successfully completed. The instance is stored in the state as well in case we need to
+ * run datasource-specific code. Existing queries are imported to the new datasource if an importer exists,
+ * e.g., Prometheus -> Loki queries.
+ */
+export const loadDatasourceSuccess = (
+  exploreId: ExploreId,
+  instance: any,
+  queries: DataQuery[]
+): LoadDatasourceSuccessAction => {
+  // Capabilities
+  const supportsGraph = instance.meta.metrics;
+  const supportsLogs = instance.meta.logs;
+  const supportsTable = instance.meta.tables;
+  // Custom components
+  const StartPage = instance.pluginExports.ExploreStartPage;
+
+  const historyKey = `grafana.explore.history.${instance.meta.id}`;
+  const history = store.getObject(historyKey, []);
+  // Save last-used datasource
+  store.set(LAST_USED_DATASOURCE_KEY, instance.name);
+
+  return {
+    type: ActionTypes.LoadDatasourceSuccess,
+    payload: {
+      exploreId,
+      StartPage,
+      datasourceInstance: instance,
+      history,
+      initialDatasource: instance.name,
+      initialQueries: queries,
+      showingStartPage: Boolean(StartPage),
+      supportsGraph,
+      supportsLogs,
+      supportsTable,
+    },
+  };
+};
+
+/**
+ * Main action to asynchronously load a datasource. Dispatches lots of smaller actions for feedback.
+ */
+export function loadDatasource(exploreId: ExploreId, instance: any): ThunkResult<void> {
+  return async (dispatch, getState) => {
+    const datasourceId = instance.meta.id;
+
+    // Keep ID to track selection
+    dispatch(loadDatasourcePending(exploreId, datasourceId));
+
+    let datasourceError = null;
+    try {
+      const testResult = await instance.testDatasource();
+      datasourceError = testResult.status === 'success' ? null : testResult.message;
+    } catch (error) {
+      datasourceError = (error && error.statusText) || 'Network error';
+    }
+    if (datasourceError) {
+      dispatch(loadDatasourceFailure(exploreId, datasourceError));
+      return;
+    }
+
+    if (datasourceId !== getState().explore[exploreId].requestedDatasourceId) {
+      // User already changed datasource again, discard results
+      return;
+    }
+
+    if (instance.init) {
+      instance.init();
+    }
+
+    // Check if queries can be imported from previously selected datasource
+    const queries = getState().explore[exploreId].modifiedQueries;
+    let importedQueries = queries;
+    const origin = getState().explore[exploreId].datasourceInstance;
+    if (origin) {
+      if (origin.meta.id === instance.meta.id) {
+        // Keep same queries if same type of datasource
+        importedQueries = [...queries];
+      } else if (instance.importQueries) {
+        // Datasource-specific importers
+        importedQueries = await instance.importQueries(queries, origin.meta);
+      } else {
+        // Default is blank queries
+        importedQueries = ensureQueries();
+      }
+    }
+
+    if (datasourceId !== getState().explore[exploreId].requestedDatasourceId) {
+      // User already changed datasource again, discard results
+      return;
+    }
+
+    // Reset edit state with new queries
+    const nextQueries = importedQueries.map((q, i) => ({
+      ...importedQueries[i],
+      ...generateEmptyQuery(i),
+    }));
+
+    dispatch(loadDatasourceSuccess(exploreId, instance, nextQueries));
+    dispatch(runQueries(exploreId));
+  };
+}
+
+/**
+ * Action to modify a query given a datasource-specific modifier action.
+ * @param exploreId Explore area
+ * @param modification Action object with a type, e.g., ADD_FILTER
+ * @param index Optional query row index. If omitted, the modification is applied to all query rows.
+ * @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`.
+ */
+export function modifyQueries(
+  exploreId: ExploreId,
+  modification: any,
+  index: number,
+  modifier: any
+): ThunkResult<void> {
+  return dispatch => {
+    dispatch({ type: ActionTypes.ModifyQueries, payload: { exploreId, modification, index, modifier } });
+    if (!modification.preventSubmit) {
+      dispatch(runQueries(exploreId));
+    }
+  };
+}
+
+/**
+ * Mark a query transaction as failed with an error extracted from the query response.
+ * The transaction will be marked as `done`.
+ */
+export function queryTransactionFailure(
+  exploreId: ExploreId,
+  transactionId: string,
+  response: any,
+  datasourceId: string
+): ThunkResult<void> {
+  return (dispatch, getState) => {
+    const { datasourceInstance, queryTransactions } = getState().explore[exploreId];
+    if (datasourceInstance.meta.id !== datasourceId || response.cancelled) {
+      // Navigated away, queries did not matter
+      return;
+    }
+
+    // Transaction might have been discarded
+    if (!queryTransactions.find(qt => qt.id === transactionId)) {
+      return;
+    }
+
+    console.error(response);
+
+    let error: string;
+    let errorDetails: string;
+    if (response.data) {
+      if (typeof response.data === 'string') {
+        error = response.data;
+      } else if (response.data.error) {
+        error = response.data.error;
+        if (response.data.response) {
+          errorDetails = response.data.response;
+        }
+      } else {
+        throw new Error('Could not handle error response');
+      }
+    } else if (response.message) {
+      error = response.message;
+    } else if (typeof response === 'string') {
+      error = response;
+    } else {
+      error = 'Unknown error during query transaction. Please check JS console logs.';
+    }
+
+    // Mark transactions as complete
+    const nextQueryTransactions = queryTransactions.map(qt => {
+      if (qt.id === transactionId) {
+        return {
+          ...qt,
+          error,
+          errorDetails,
+          done: true,
+        };
+      }
+      return qt;
+    });
+
+    dispatch({
+      type: ActionTypes.QueryTransactionFailure,
+      payload: { exploreId, queryTransactions: nextQueryTransactions },
+    });
+  };
+}
+
+/**
+ * Start a query transaction for the given result type.
+ * @param exploreId Explore area
+ * @param transaction Query options and `done` status.
+ * @param resultType Associate the transaction with a result viewer, e.g., Graph
+ * @param rowIndex Index is used to associate latency for this transaction with a query row
+ */
+export function queryTransactionStart(
+  exploreId: ExploreId,
+  transaction: QueryTransaction,
+  resultType: ResultType,
+  rowIndex: number
+): QueryTransactionStartAction {
+  return { type: ActionTypes.QueryTransactionStart, payload: { exploreId, resultType, rowIndex, transaction } };
+}
+
+/**
+ * Complete a query transaction, mark the transaction as `done` and store query state in URL.
+ * If the transaction was started by a scanner, it keeps on scanning for more results.
+ * Side-effect: the query is stored in localStorage.
+ * @param exploreId Explore area
+ * @param transactionId ID
+ * @param result Response from `datasourceInstance.query()`
+ * @param latency Duration between request and response
+ * @param queries Queries from all query rows
+ * @param datasourceId Origin datasource instance, used to discard results if current datasource is different
+ */
+export function queryTransactionSuccess(
+  exploreId: ExploreId,
+  transactionId: string,
+  result: any,
+  latency: number,
+  queries: DataQuery[],
+  datasourceId: string
+): ThunkResult<void> {
+  return (dispatch, getState) => {
+    const { datasourceInstance, history, queryTransactions, scanner, scanning } = getState().explore[exploreId];
+
+    // If datasource already changed, results do not matter
+    if (datasourceInstance.meta.id !== datasourceId) {
+      return;
+    }
+
+    // Transaction might have been discarded
+    const transaction = queryTransactions.find(qt => qt.id === transactionId);
+    if (!transaction) {
+      return;
+    }
+
+    // Get query hints
+    let hints: QueryHint[];
+    if (datasourceInstance.getQueryHints as QueryHintGetter) {
+      hints = datasourceInstance.getQueryHints(transaction.query, result);
+    }
+
+    // Mark transactions as complete and attach result
+    const nextQueryTransactions = queryTransactions.map(qt => {
+      if (qt.id === transactionId) {
+        return {
+          ...qt,
+          hints,
+          latency,
+          result,
+          done: true,
+        };
+      }
+      return qt;
+    });
+
+    // Side-effect: Saving history in localstorage
+    const nextHistory = updateHistory(history, datasourceId, queries);
+
+    dispatch({
+      type: ActionTypes.QueryTransactionSuccess,
+      payload: {
+        exploreId,
+        history: nextHistory,
+        queryTransactions: nextQueryTransactions,
+      },
+    });
+
+    // Keep scanning for results if this was the last scanning transaction
+    if (scanning) {
+      if (_.size(result) === 0) {
+        const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done);
+        if (!other) {
+          const range = scanner();
+          dispatch({ type: ActionTypes.ScanRange, payload: { exploreId, range } });
+        }
+      } else {
+        // We can stop scanning if we have a result
+        dispatch(scanStop(exploreId));
+      }
+    }
+  };
+}
+
+/**
+ * Remove query row of the given index, as well as associated query results.
+ */
+export function removeQueryRow(exploreId: ExploreId, index: number): ThunkResult<void> {
+  return dispatch => {
+    dispatch({ type: ActionTypes.RemoveQueryRow, payload: { exploreId, index } });
+    dispatch(runQueries(exploreId));
+  };
+}
+
+/**
+ * Main action to run queries and dispatches sub-actions based on which result viewers are active
+ */
+export function runQueries(exploreId: ExploreId) {
+  return (dispatch, getState) => {
+    const {
+      datasourceInstance,
+      modifiedQueries,
+      showingLogs,
+      showingGraph,
+      showingTable,
+      supportsGraph,
+      supportsLogs,
+      supportsTable,
+    } = getState().explore[exploreId];
+
+    if (!hasNonEmptyQuery(modifiedQueries)) {
+      dispatch({ type: ActionTypes.RunQueriesEmpty, payload: { exploreId } });
+      return;
+    }
+
+    // Some datasource's query builders allow per-query interval limits,
+    // but we're using the datasource interval limit for now
+    const interval = datasourceInstance.interval;
+
+    // Keep table queries first since they need to return quickly
+    if (showingTable && supportsTable) {
+      dispatch(
+        runQueriesForType(
+          exploreId,
+          'Table',
+          {
+            interval,
+            format: 'table',
+            instant: true,
+            valueWithRefId: true,
+          },
+          data => data[0]
+        )
+      );
+    }
+    if (showingGraph && supportsGraph) {
+      dispatch(
+        runQueriesForType(
+          exploreId,
+          'Graph',
+          {
+            interval,
+            format: 'time_series',
+            instant: false,
+          },
+          makeTimeSeriesList
+        )
+      );
+    }
+    if (showingLogs && supportsLogs) {
+      dispatch(runQueriesForType(exploreId, 'Logs', { interval, format: 'logs' }));
+    }
+    dispatch(stateSave());
+  };
+}
+
+/**
+ * Helper action to build a query transaction object and handing the query to the datasource.
+ * @param exploreId Explore area
+ * @param resultType Result viewer that will be associated with this query result
+ * @param queryOptions Query options as required by the datasource's `query()` function.
+ * @param resultGetter Optional result extractor, e.g., if the result is a list and you only need the first element.
+ */
+function runQueriesForType(
+  exploreId: ExploreId,
+  resultType: ResultType,
+  queryOptions: QueryOptions,
+  resultGetter?: any
+) {
+  return async (dispatch, getState) => {
+    const {
+      datasourceInstance,
+      eventBridge,
+      modifiedQueries: queries,
+      queryIntervals,
+      range,
+      scanning,
+    } = getState().explore[exploreId];
+    const datasourceId = datasourceInstance.meta.id;
+
+    // Run all queries concurrently
+    queries.forEach(async (query, rowIndex) => {
+      const transaction = buildQueryTransaction(
+        query,
+        rowIndex,
+        resultType,
+        queryOptions,
+        range,
+        queryIntervals,
+        scanning
+      );
+      dispatch(queryTransactionStart(exploreId, transaction, resultType, rowIndex));
+      try {
+        const now = Date.now();
+        const res = await datasourceInstance.query(transaction.options);
+        eventBridge.emit('data-received', res.data || []);
+        const latency = Date.now() - now;
+        const results = resultGetter ? resultGetter(res.data) : res.data;
+        dispatch(queryTransactionSuccess(exploreId, transaction.id, results, latency, queries, datasourceId));
+      } catch (response) {
+        eventBridge.emit('data-error', response);
+        dispatch(queryTransactionFailure(exploreId, transaction.id, response, datasourceId));
+      }
+    });
+  };
+}
+
+/**
+ * Start a scan for more results using the given scanner.
+ * @param exploreId Explore area
+ * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range
+ */
+export function scanStart(exploreId: ExploreId, scanner: RangeScanner): ThunkResult<void> {
+  return dispatch => {
+    // Register the scanner
+    dispatch({ type: ActionTypes.ScanStart, payload: { exploreId, scanner } });
+    // Scanning must trigger query run, and return the new range
+    const range = scanner();
+    // Set the new range to be displayed
+    dispatch({ type: ActionTypes.ScanRange, payload: { exploreId, range } });
+  };
+}
+
+/**
+ * Stop any scanning for more results.
+ */
+export function scanStop(exploreId: ExploreId): ScanStopAction {
+  return { type: ActionTypes.ScanStop, payload: { exploreId } };
+}
+
+/**
+ * Reset queries to the given queries. Any modifications will be discarded.
+ * Use this action for clicks on query examples. Triggers a query run.
+ */
+export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): ThunkResult<void> {
+  return dispatch => {
+    // Inject react keys into query objects
+    const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery() }));
+    dispatch({
+      type: ActionTypes.SetQueries,
+      payload: {
+        exploreId,
+        queries,
+      },
+    });
+    dispatch(runQueries(exploreId));
+  };
+}
+
+/**
+ * Close the split view and save URL state.
+ */
+export function splitClose(): ThunkResult<void> {
+  return dispatch => {
+    dispatch({ type: ActionTypes.SplitClose });
+    dispatch(stateSave());
+  };
+}
+
+/**
+ * Open the split view and copy the left state to be the right state.
+ * The right state is automatically initialized.
+ * The copy keeps all query modifications but wipes the query results.
+ */
+export function splitOpen(): ThunkResult<void> {
+  return (dispatch, getState) => {
+    // Clone left state to become the right state
+    const leftState = getState().explore.left;
+    const itemState = {
+      ...leftState,
+      queryTransactions: [],
+      initialQueries: leftState.modifiedQueries.slice(),
+    };
+    dispatch({ type: ActionTypes.SplitOpen, payload: { itemState } });
+    dispatch(stateSave());
+  };
+}
+
+/**
+ * Saves Explore state to URL using the `left` and `right` parameters.
+ * If split view is not active, `right` will not be set.
+ */
+export function stateSave() {
+  return (dispatch, getState) => {
+    const { left, right, split } = getState().explore;
+    const urlStates: { [index: string]: string } = {};
+    const leftUrlState: ExploreUrlState = {
+      datasource: left.datasourceInstance.name,
+      queries: left.modifiedQueries.map(clearQueryKeys),
+      range: left.range,
+    };
+    urlStates.left = serializeStateToUrlParam(leftUrlState, true);
+    if (split) {
+      const rightUrlState: ExploreUrlState = {
+        datasource: right.datasourceInstance.name,
+        queries: right.modifiedQueries.map(clearQueryKeys),
+        range: right.range,
+      };
+      urlStates.right = serializeStateToUrlParam(rightUrlState, true);
+    }
+    dispatch(updateLocation({ query: urlStates }));
+  };
+}
+
+/**
+ * Expand/collapse the graph result viewer. When collapsed, graph queries won't be run.
+ */
+export function toggleGraph(exploreId: ExploreId): ThunkResult<void> {
+  return (dispatch, getState) => {
+    dispatch({ type: ActionTypes.ToggleGraph, payload: { exploreId } });
+    if (getState().explore[exploreId].showingGraph) {
+      dispatch(runQueries(exploreId));
+    }
+  };
+}
+
+/**
+ * Expand/collapse the logs result viewer. When collapsed, log queries won't be run.
+ */
+export function toggleLogs(exploreId: ExploreId): ThunkResult<void> {
+  return (dispatch, getState) => {
+    dispatch({ type: ActionTypes.ToggleLogs, payload: { exploreId } });
+    if (getState().explore[exploreId].showingLogs) {
+      dispatch(runQueries(exploreId));
+    }
+  };
+}
+
+/**
+ * Expand/collapse the table result viewer. When collapsed, table queries won't be run.
+ */
+export function toggleTable(exploreId: ExploreId): ThunkResult<void> {
+  return (dispatch, getState) => {
+    dispatch({ type: ActionTypes.ToggleTable, payload: { exploreId } });
+    if (getState().explore[exploreId].showingTable) {
+      dispatch(runQueries(exploreId));
+    }
+  };
+}

+ 462 - 0
public/app/features/explore/state/reducers.ts

@@ -0,0 +1,462 @@
+import {
+  calculateResultsFromQueryTransactions,
+  generateEmptyQuery,
+  getIntervals,
+  ensureQueries,
+} from 'app/core/utils/explore';
+import { ExploreItemState, ExploreState, QueryTransaction } from 'app/types/explore';
+import { DataQuery } from 'app/types/series';
+
+import { Action, ActionTypes } from './actionTypes';
+
+export const DEFAULT_RANGE = {
+  from: 'now-6h',
+  to: 'now',
+};
+
+// Millies step for helper bar charts
+const DEFAULT_GRAPH_INTERVAL = 15 * 1000;
+
+/**
+ * Returns a fresh Explore area state
+ */
+const makeExploreItemState = (): ExploreItemState => ({
+  StartPage: undefined,
+  containerWidth: 0,
+  datasourceInstance: null,
+  datasourceError: null,
+  datasourceLoading: null,
+  datasourceMissing: false,
+  exploreDatasources: [],
+  history: [],
+  initialQueries: [],
+  initialized: false,
+  modifiedQueries: [],
+  queryTransactions: [],
+  queryIntervals: { interval: '15s', intervalMs: DEFAULT_GRAPH_INTERVAL },
+  range: DEFAULT_RANGE,
+  scanning: false,
+  scanRange: null,
+  showingGraph: true,
+  showingLogs: true,
+  showingTable: true,
+  supportsGraph: null,
+  supportsLogs: null,
+  supportsTable: null,
+});
+
+/**
+ * Global Explore state that handles multiple Explore areas and the split state
+ */
+const initialExploreState: ExploreState = {
+  split: null,
+  left: makeExploreItemState(),
+  right: makeExploreItemState(),
+};
+
+/**
+ * Reducer for an Explore area, to be used by the global Explore reducer.
+ */
+const itemReducer = (state, action: Action): ExploreItemState => {
+  switch (action.type) {
+    case ActionTypes.AddQueryRow: {
+      const { initialQueries, modifiedQueries, queryTransactions } = state;
+      const { index, query } = action.payload;
+
+      // Add new query row after given index, keep modifications of existing rows
+      const nextModifiedQueries = [
+        ...modifiedQueries.slice(0, index + 1),
+        { ...query },
+        ...initialQueries.slice(index + 1),
+      ];
+
+      // Add to initialQueries, which will cause a new row to be rendered
+      const nextQueries = [...initialQueries.slice(0, index + 1), { ...query }, ...initialQueries.slice(index + 1)];
+
+      // Ongoing transactions need to update their row indices
+      const nextQueryTransactions = queryTransactions.map(qt => {
+        if (qt.rowIndex > index) {
+          return {
+            ...qt,
+            rowIndex: qt.rowIndex + 1,
+          };
+        }
+        return qt;
+      });
+
+      return {
+        ...state,
+        initialQueries: nextQueries,
+        logsHighlighterExpressions: undefined,
+        modifiedQueries: nextModifiedQueries,
+        queryTransactions: nextQueryTransactions,
+      };
+    }
+
+    case ActionTypes.ChangeQuery: {
+      const { initialQueries, queryTransactions } = state;
+      let { modifiedQueries } = state;
+      const { query, index, override } = action.payload;
+
+      // Fast path: only change modifiedQueries to not trigger an update
+      modifiedQueries[index] = query;
+      if (!override) {
+        return {
+          ...state,
+          modifiedQueries,
+        };
+      }
+
+      // Override path: queries are completely reset
+      const nextQuery: DataQuery = {
+        ...query,
+        ...generateEmptyQuery(index),
+      };
+      const nextQueries = [...initialQueries];
+      nextQueries[index] = nextQuery;
+      modifiedQueries = [...nextQueries];
+
+      // Discard ongoing transaction related to row query
+      const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
+
+      return {
+        ...state,
+        initialQueries: nextQueries,
+        modifiedQueries: nextQueries.slice(),
+        queryTransactions: nextQueryTransactions,
+      };
+    }
+
+    case ActionTypes.ChangeSize: {
+      const { range, datasourceInstance } = state;
+      let interval = '1s';
+      if (datasourceInstance && datasourceInstance.interval) {
+        interval = datasourceInstance.interval;
+      }
+      const containerWidth = action.payload.width;
+      const queryIntervals = getIntervals(range, interval, containerWidth);
+      return { ...state, containerWidth, queryIntervals };
+    }
+
+    case ActionTypes.ChangeTime: {
+      return {
+        ...state,
+        range: action.payload.range,
+      };
+    }
+
+    case ActionTypes.ClearQueries: {
+      const queries = ensureQueries();
+      return {
+        ...state,
+        initialQueries: queries.slice(),
+        modifiedQueries: queries.slice(),
+        queryTransactions: [],
+        showingStartPage: Boolean(state.StartPage),
+      };
+    }
+
+    case ActionTypes.HighlightLogsExpression: {
+      const { expressions } = action.payload;
+      return { ...state, logsHighlighterExpressions: expressions };
+    }
+
+    case ActionTypes.InitializeExplore: {
+      const { containerWidth, datasource, eventBridge, exploreDatasources, queries, range } = action.payload;
+      return {
+        ...state,
+        containerWidth,
+        eventBridge,
+        exploreDatasources,
+        range,
+        initialDatasource: datasource,
+        initialQueries: queries,
+        initialized: true,
+        modifiedQueries: queries.slice(),
+      };
+    }
+
+    case ActionTypes.LoadDatasourceFailure: {
+      return { ...state, datasourceError: action.payload.error, datasourceLoading: false };
+    }
+
+    case ActionTypes.LoadDatasourceMissing: {
+      return { ...state, datasourceMissing: true, datasourceLoading: false };
+    }
+
+    case ActionTypes.LoadDatasourcePending: {
+      return { ...state, datasourceLoading: true, requestedDatasourceId: action.payload.datasourceId };
+    }
+
+    case ActionTypes.LoadDatasourceSuccess: {
+      const { containerWidth, range } = state;
+      const {
+        StartPage,
+        datasourceInstance,
+        history,
+        initialDatasource,
+        initialQueries,
+        showingStartPage,
+        supportsGraph,
+        supportsLogs,
+        supportsTable,
+      } = action.payload;
+      const queryIntervals = getIntervals(range, datasourceInstance.interval, containerWidth);
+
+      return {
+        ...state,
+        queryIntervals,
+        StartPage,
+        datasourceInstance,
+        history,
+        initialDatasource,
+        initialQueries,
+        showingStartPage,
+        supportsGraph,
+        supportsLogs,
+        supportsTable,
+        datasourceLoading: false,
+        datasourceMissing: false,
+        logsHighlighterExpressions: undefined,
+        modifiedQueries: initialQueries.slice(),
+        queryTransactions: [],
+      };
+    }
+
+    case ActionTypes.ModifyQueries: {
+      const { initialQueries, modifiedQueries, queryTransactions } = state;
+      const { modification, index, modifier } = action.payload as any;
+      let nextQueries: DataQuery[];
+      let nextQueryTransactions;
+      if (index === undefined) {
+        // Modify all queries
+        nextQueries = initialQueries.map((query, i) => ({
+          ...modifier(modifiedQueries[i], modification),
+          ...generateEmptyQuery(i),
+        }));
+        // Discard all ongoing transactions
+        nextQueryTransactions = [];
+      } else {
+        // Modify query only at index
+        nextQueries = initialQueries.map((query, i) => {
+          // Synchronize all queries with local query cache to ensure consistency
+          // TODO still needed?
+          return i === index
+            ? {
+                ...modifier(modifiedQueries[i], modification),
+                ...generateEmptyQuery(i),
+              }
+            : query;
+        });
+        nextQueryTransactions = queryTransactions
+          // Consume the hint corresponding to the action
+          .map(qt => {
+            if (qt.hints != null && qt.rowIndex === index) {
+              qt.hints = qt.hints.filter(hint => hint.fix.action !== modification);
+            }
+            return qt;
+          })
+          // Preserve previous row query transaction to keep results visible if next query is incomplete
+          .filter(qt => modification.preventSubmit || qt.rowIndex !== index);
+      }
+      return {
+        ...state,
+        initialQueries: nextQueries,
+        modifiedQueries: nextQueries.slice(),
+        queryTransactions: nextQueryTransactions,
+      };
+    }
+
+    case ActionTypes.QueryTransactionFailure: {
+      const { queryTransactions } = action.payload;
+      return {
+        ...state,
+        queryTransactions,
+        showingStartPage: false,
+      };
+    }
+
+    case ActionTypes.QueryTransactionStart: {
+      const { datasourceInstance, queryIntervals, queryTransactions } = state;
+      const { resultType, rowIndex, transaction } = action.payload;
+      // Discarding existing transactions of same type
+      const remainingTransactions = queryTransactions.filter(
+        qt => !(qt.resultType === resultType && qt.rowIndex === rowIndex)
+      );
+
+      // Append new transaction
+      const nextQueryTransactions: QueryTransaction[] = [...remainingTransactions, transaction];
+
+      const results = calculateResultsFromQueryTransactions(
+        nextQueryTransactions,
+        datasourceInstance,
+        queryIntervals.intervalMs
+      );
+
+      return {
+        ...state,
+        ...results,
+        queryTransactions: nextQueryTransactions,
+        showingStartPage: false,
+      };
+    }
+
+    case ActionTypes.QueryTransactionSuccess: {
+      const { datasourceInstance, queryIntervals } = state;
+      const { history, queryTransactions } = action.payload;
+      const results = calculateResultsFromQueryTransactions(
+        queryTransactions,
+        datasourceInstance,
+        queryIntervals.intervalMs
+      );
+
+      return {
+        ...state,
+        ...results,
+        history,
+        queryTransactions,
+        showingStartPage: false,
+      };
+    }
+
+    case ActionTypes.RemoveQueryRow: {
+      const { datasourceInstance, initialQueries, queryIntervals, queryTransactions } = state;
+      let { modifiedQueries } = state;
+      const { index } = action.payload;
+
+      modifiedQueries = [...modifiedQueries.slice(0, index), ...modifiedQueries.slice(index + 1)];
+
+      if (initialQueries.length <= 1) {
+        return state;
+      }
+
+      const nextQueries = [...initialQueries.slice(0, index), ...initialQueries.slice(index + 1)];
+
+      // Discard transactions related to row query
+      const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
+      const results = calculateResultsFromQueryTransactions(
+        nextQueryTransactions,
+        datasourceInstance,
+        queryIntervals.intervalMs
+      );
+
+      return {
+        ...state,
+        ...results,
+        initialQueries: nextQueries,
+        logsHighlighterExpressions: undefined,
+        modifiedQueries: nextQueries.slice(),
+        queryTransactions: nextQueryTransactions,
+      };
+    }
+
+    case ActionTypes.RunQueriesEmpty: {
+      return { ...state, queryTransactions: [] };
+    }
+
+    case ActionTypes.ScanRange: {
+      return { ...state, scanRange: action.payload.range };
+    }
+
+    case ActionTypes.ScanStart: {
+      return { ...state, scanning: true };
+    }
+
+    case ActionTypes.ScanStop: {
+      const { queryTransactions } = state;
+      const nextQueryTransactions = queryTransactions.filter(qt => qt.scanning && !qt.done);
+      return { ...state, queryTransactions: nextQueryTransactions, scanning: false, scanRange: undefined };
+    }
+
+    case ActionTypes.SetQueries: {
+      const { queries } = action.payload;
+      return { ...state, initialQueries: queries.slice(), modifiedQueries: queries.slice() };
+    }
+
+    case ActionTypes.ToggleGraph: {
+      const showingGraph = !state.showingGraph;
+      let nextQueryTransactions = state.queryTransactions;
+      if (!showingGraph) {
+        // Discard transactions related to Graph query
+        nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Graph');
+      }
+      return { ...state, queryTransactions: nextQueryTransactions, showingGraph };
+    }
+
+    case ActionTypes.ToggleLogs: {
+      const showingLogs = !state.showingLogs;
+      let nextQueryTransactions = state.queryTransactions;
+      if (!showingLogs) {
+        // Discard transactions related to Logs query
+        nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Logs');
+      }
+      return { ...state, queryTransactions: nextQueryTransactions, showingLogs };
+    }
+
+    case ActionTypes.ToggleTable: {
+      const showingTable = !state.showingTable;
+      if (showingTable) {
+        return { ...state, showingTable, queryTransactions: state.queryTransactions };
+      }
+
+      // Toggle off needs discarding of table queries and results
+      const nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table');
+      const results = calculateResultsFromQueryTransactions(
+        nextQueryTransactions,
+        state.datasourceInstance,
+        state.queryIntervals.intervalMs
+      );
+
+      return { ...state, ...results, queryTransactions: nextQueryTransactions, showingTable };
+    }
+  }
+
+  return state;
+};
+
+/**
+ * Global Explore reducer that handles multiple Explore areas (left and right).
+ * Actions that have an `exploreId` get routed to the ExploreItemReducer.
+ */
+export const exploreReducer = (state = initialExploreState, action: Action): ExploreState => {
+  switch (action.type) {
+    case ActionTypes.SplitClose: {
+      return {
+        ...state,
+        split: false,
+      };
+    }
+
+    case ActionTypes.SplitOpen: {
+      return {
+        ...state,
+        split: true,
+        right: action.payload.itemState,
+      };
+    }
+
+    case ActionTypes.InitializeExploreSplit: {
+      return {
+        ...state,
+        split: true,
+      };
+    }
+  }
+
+  if (action.payload) {
+    const { exploreId } = action.payload as any;
+    if (exploreId !== undefined) {
+      const exploreItemState = state[exploreId];
+      return {
+        ...state,
+        [exploreId]: itemReducer(exploreItemState, action),
+      };
+    }
+  }
+
+  return state;
+};
+
+export default {
+  explore: exploreReducer,
+};

+ 1 - 1
public/app/features/teams/TeamSettings.tsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 import { connect } from 'react-redux';
 import { connect } from 'react-redux';
-
 import { Label } from '@grafana/ui';
 import { Label } from '@grafana/ui';
+
 import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences';
 import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences';
 import { updateTeam } from './state/actions';
 import { updateTeam } from './state/actions';
 import { getRouteParamsId } from 'app/core/selectors/location';
 import { getRouteParamsId } from 'app/core/selectors/location';

+ 1 - 1
public/app/features/templating/variable_srv.ts

@@ -132,7 +132,7 @@ export class VariableSrv {
 
 
     return this.$q.all(promises).then(() => {
     return this.$q.all(promises).then(() => {
       if (emitChangeEvents) {
       if (emitChangeEvents) {
-        this.$rootScope.$emit('template-variable-value-updated');
+        this.$rootScope.appEvent('template-variable-value-updated');
         this.dashboard.startRefresh();
         this.dashboard.startRefresh();
       }
       }
     });
     });

+ 2 - 2
public/app/plugins/panel/gauge/GaugeOptionsEditor.tsx

@@ -1,8 +1,8 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
-import { GaugeOptions, PanelOptionsProps, PanelOptionsGroup } from '@grafana/ui';
+import { FormField, PanelOptionsProps, PanelOptionsGroup } from '@grafana/ui';
 
 
 import { Switch } from 'app/core/components/Switch/Switch';
 import { Switch } from 'app/core/components/Switch/Switch';
-import { FormField } from '@grafana/ui/src';
+import { GaugeOptions } from './types';
 
 
 export default class GaugeOptionsEditor extends PureComponent<PanelOptionsProps<GaugeOptions>> {
 export default class GaugeOptionsEditor extends PureComponent<PanelOptionsProps<GaugeOptions>> {
   onToggleThresholdLabels = () =>
   onToggleThresholdLabels = () =>

+ 8 - 2
public/app/plugins/panel/gauge/GaugePanel.tsx

@@ -1,14 +1,18 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
-import { GaugeOptions, PanelProps, NullValueMode } from '@grafana/ui';
+import { PanelProps, NullValueMode } from '@grafana/ui';
 
 
 import { getTimeSeriesVMs } from 'app/viz/state/timeSeries';
 import { getTimeSeriesVMs } from 'app/viz/state/timeSeries';
 import Gauge from 'app/viz/Gauge';
 import Gauge from 'app/viz/Gauge';
+import { GaugeOptions } from './types';
 
 
 interface Props extends PanelProps<GaugeOptions> {}
 interface Props extends PanelProps<GaugeOptions> {}
 
 
 export class GaugePanel extends PureComponent<Props> {
 export class GaugePanel extends PureComponent<Props> {
   render() {
   render() {
-    const { timeSeries, width, height } = this.props;
+    const { timeSeries, width, height, onInterpolate, options } = this.props;
+
+    const prefix = onInterpolate(options.prefix);
+    const suffix = onInterpolate(options.suffix);
 
 
     const vmSeries = getTimeSeriesVMs({
     const vmSeries = getTimeSeriesVMs({
       timeSeries: timeSeries,
       timeSeries: timeSeries,
@@ -21,6 +25,8 @@ export class GaugePanel extends PureComponent<Props> {
         {...this.props.options}
         {...this.props.options}
         width={width}
         width={width}
         height={height}
         height={height}
+        prefix={prefix}
+        suffix={suffix}
       />
       />
     );
     );
   }
   }

+ 16 - 5
public/app/plugins/panel/gauge/GaugePanelOptions.tsx

@@ -1,16 +1,17 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
 import {
 import {
   BasicGaugeColor,
   BasicGaugeColor,
-  GaugeOptions,
   PanelOptionsProps,
   PanelOptionsProps,
   ThresholdsEditor,
   ThresholdsEditor,
   Threshold,
   Threshold,
   PanelOptionsGrid,
   PanelOptionsGrid,
+  ValueMappingsEditor,
+  ValueMapping,
 } from '@grafana/ui';
 } from '@grafana/ui';
 
 
 import ValueOptions from 'app/plugins/panel/gauge/ValueOptions';
 import ValueOptions from 'app/plugins/panel/gauge/ValueOptions';
-import ValueMappings from 'app/plugins/panel/gauge/ValueMappings';
 import GaugeOptionsEditor from './GaugeOptionsEditor';
 import GaugeOptionsEditor from './GaugeOptionsEditor';
+import { GaugeOptions } from './types';
 
 
 export const defaultProps = {
 export const defaultProps = {
   options: {
   options: {
@@ -24,7 +25,7 @@ export const defaultProps = {
     decimals: 0,
     decimals: 0,
     stat: 'avg',
     stat: 'avg',
     unit: 'none',
     unit: 'none',
-    mappings: [],
+    valueMappings: [],
     thresholds: [],
     thresholds: [],
   },
   },
 };
 };
@@ -32,7 +33,17 @@ export const defaultProps = {
 export default class GaugePanelOptions extends PureComponent<PanelOptionsProps<GaugeOptions>> {
 export default class GaugePanelOptions extends PureComponent<PanelOptionsProps<GaugeOptions>> {
   static defaultProps = defaultProps;
   static defaultProps = defaultProps;
 
 
-  onThresholdsChanged = (thresholds: Threshold[]) => this.props.onChange({ ...this.props.options, thresholds });
+  onThresholdsChanged = (thresholds: Threshold[]) =>
+    this.props.onChange({
+      ...this.props.options,
+      thresholds,
+    });
+
+  onValueMappingsChanged = (valueMappings: ValueMapping[]) =>
+    this.props.onChange({
+      ...this.props.options,
+      valueMappings,
+    });
 
 
   render() {
   render() {
     const { onChange, options } = this.props;
     const { onChange, options } = this.props;
@@ -44,7 +55,7 @@ export default class GaugePanelOptions extends PureComponent<PanelOptionsProps<G
           <ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={options.thresholds} />
           <ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={options.thresholds} />
         </PanelOptionsGrid>
         </PanelOptionsGrid>
 
 
-        <ValueMappings onChange={onChange} options={options} />
+        <ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={options.valueMappings} />
       </>
       </>
     );
     );
   }
   }

+ 2 - 1
public/app/plugins/panel/gauge/ValueOptions.tsx

@@ -1,6 +1,7 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
-import { FormField, Label, GaugeOptions, PanelOptionsProps, PanelOptionsGroup, Select } from '@grafana/ui';
+import { FormField, Label,  PanelOptionsProps, PanelOptionsGroup, Select } from '@grafana/ui';
 import UnitPicker from 'app/core/components/Select/UnitPicker';
 import UnitPicker from 'app/core/components/Select/UnitPicker';
+import { GaugeOptions } from './types';
 
 
 const statOptions = [
 const statOptions = [
   { value: 'min', label: 'Min' },
   { value: 'min', label: 'Min' },

+ 15 - 1
public/app/plugins/panel/gauge/types.ts

@@ -1,2 +1,16 @@
+import { Threshold, ValueMapping } from '@grafana/ui';
 
 
-
+export interface GaugeOptions {
+  baseColor: string;
+  decimals: number;
+  valueMappings: ValueMapping[];
+  maxValue: number;
+  minValue: number;
+  prefix: string;
+  showThresholdLabels: boolean;
+  showThresholdMarkers: boolean;
+  stat: string;
+  suffix: string;
+  thresholds: Threshold[];
+  unit: string;
+}

+ 2 - 0
public/app/store/configureStore.ts

@@ -7,6 +7,7 @@ import teamsReducers from 'app/features/teams/state/reducers';
 import apiKeysReducers from 'app/features/api-keys/state/reducers';
 import apiKeysReducers from 'app/features/api-keys/state/reducers';
 import foldersReducers from 'app/features/folders/state/reducers';
 import foldersReducers from 'app/features/folders/state/reducers';
 import dashboardReducers from 'app/features/dashboard/state/reducers';
 import dashboardReducers from 'app/features/dashboard/state/reducers';
+import exploreReducers from 'app/features/explore/state/reducers';
 import pluginReducers from 'app/features/plugins/state/reducers';
 import pluginReducers from 'app/features/plugins/state/reducers';
 import dataSourcesReducers from 'app/features/datasources/state/reducers';
 import dataSourcesReducers from 'app/features/datasources/state/reducers';
 import usersReducers from 'app/features/users/state/reducers';
 import usersReducers from 'app/features/users/state/reducers';
@@ -20,6 +21,7 @@ const rootReducers = {
   ...apiKeysReducers,
   ...apiKeysReducers,
   ...foldersReducers,
   ...foldersReducers,
   ...dashboardReducers,
   ...dashboardReducers,
+  ...exploreReducers,
   ...pluginReducers,
   ...pluginReducers,
   ...dataSourcesReducers,
   ...dataSourcesReducers,
   ...usersReducers,
   ...usersReducers,

+ 188 - 37
public/app/types/explore.ts

@@ -1,11 +1,13 @@
 import { Value } from 'slate';
 import { Value } from 'slate';
+import { RawTimeRange, TimeRange } from '@grafana/ui';
 
 
-import { DataQuery } from './series';
-import { RawTimeRange } from '@grafana/ui';
-import TableModel from 'app/core/table_model';
+import { Emitter } from 'app/core/core';
 import { LogsModel } from 'app/core/logs_model';
 import { LogsModel } from 'app/core/logs_model';
+import TableModel from 'app/core/table_model';
 import { DataSourceSelectItem } from 'app/types/datasources';
 import { DataSourceSelectItem } from 'app/types/datasources';
 
 
+import { DataQuery } from './series';
+
 export interface CompletionItem {
 export interface CompletionItem {
   /**
   /**
    * The label of this completion item. By default
    * The label of this completion item. By default
@@ -76,6 +78,174 @@ export interface CompletionItemGroup {
   skipSort?: boolean;
   skipSort?: boolean;
 }
 }
 
 
+export enum ExploreId {
+  left = 'left',
+  right = 'right',
+}
+
+/**
+ * Global Explore state
+ */
+export interface ExploreState {
+  /**
+   * True if split view is active.
+   */
+  split: boolean;
+  /**
+   * Explore state of the left split (left is default in non-split view).
+   */
+  left: ExploreItemState;
+  /**
+   * Explore state of the right area in split view.
+   */
+  right: ExploreItemState;
+}
+
+export interface ExploreItemState {
+  /**
+   * React component to be shown when no queries have been run yet, e.g., for a query language cheat sheet.
+   */
+  StartPage?: any;
+  /**
+   * Width used for calculating the graph interval (can't have more datapoints than pixels)
+   */
+  containerWidth: number;
+  /**
+   * Datasource instance that has been selected. Datasource-specific logic can be run on this object.
+   */
+  datasourceInstance: any;
+  /**
+   * Error to be shown when datasource loading or testing failed.
+   */
+  datasourceError: string;
+  /**
+   * True if the datasource is loading. `null` if the loading has not started yet.
+   */
+  datasourceLoading: boolean | null;
+  /**
+   * True if there is no datasource to be selected.
+   */
+  datasourceMissing: boolean;
+  /**
+   * Emitter to send events to the rest of Grafana.
+   */
+  eventBridge?: Emitter;
+  /**
+   * List of datasources to be shown in the datasource selector.
+   */
+  exploreDatasources: DataSourceSelectItem[];
+  /**
+   * List of timeseries to be shown in the Explore graph result viewer.
+   */
+  graphResult?: any[];
+  /**
+   * History of recent queries. Datasource-specific and initialized via localStorage.
+   */
+  history: HistoryItem[];
+  /**
+   * Initial datasource for this Explore, e.g., set via URL.
+   */
+  initialDatasource?: string;
+  /**
+   * Initial queries for this Explore, e.g., set via URL. Each query will be
+   * converted to a query row. Query edits should be tracked in `modifiedQueries` though.
+   */
+  initialQueries: DataQuery[];
+  /**
+   * True if this Explore area has been initialized.
+   * Used to distinguish URL state injection versus split view state injection.
+   */
+  initialized: boolean;
+  /**
+   * Log line substrings to be highlighted as you type in a query field.
+   * Currently supports only the first query row.
+   */
+  logsHighlighterExpressions?: string[];
+  /**
+   * Log query result to be displayed in the logs result viewer.
+   */
+  logsResult?: LogsModel;
+  /**
+   * Copy of `initialQueries` that tracks user edits.
+   * Don't connect this property to a react component as it is updated on every query change.
+   * Used when running queries. Needs to be reset to `initialQueries` when those are reset as well.
+   */
+  modifiedQueries: DataQuery[];
+  /**
+   * Query intervals for graph queries to determine how many datapoints to return.
+   * Needs to be updated when `datasourceInstance` or `containerWidth` is changed.
+   */
+  queryIntervals: QueryIntervals;
+  /**
+   * List of query transaction to track query duration and query result.
+   * Graph/Logs/Table results are calculated on the fly from the transaction,
+   * based on the transaction's result types. Transaction also holds the row index
+   * so that results can be dropped and re-computed without running queries again
+   * when query rows are removed.
+   */
+  queryTransactions: QueryTransaction[];
+  /**
+   * Tracks datasource when selected in the datasource selector.
+   * Allows the selection to be discarded if something went wrong during the asynchronous
+   * loading of the datasource.
+   */
+  requestedDatasourceId?: number;
+  /**
+   * Time range for this Explore. Managed by the time picker and used by all query runs.
+   */
+  range: TimeRange | RawTimeRange;
+  /**
+   * Scanner function that calculates a new range, triggers a query run, and returns the new range.
+   */
+  scanner?: RangeScanner;
+  /**
+   * True if scanning for more results is active.
+   */
+  scanning?: boolean;
+  /**
+   * Current scanning range to be shown to the user while scanning is active.
+   */
+  scanRange?: RawTimeRange;
+  /**
+   * True if graph result viewer is expanded. Query runs will contain graph queries.
+   */
+  showingGraph: boolean;
+  /**
+   * True if logs result viewer is expanded. Query runs will contain logs queries.
+   */
+  showingLogs: boolean;
+  /**
+   * True StartPage needs to be shown. Typically set to `false` once queries have been run.
+   */
+  showingStartPage?: boolean;
+  /**
+   * True if table result viewer is expanded. Query runs will contain table queries.
+   */
+  showingTable: boolean;
+  /**
+   * True if `datasourceInstance` supports graph queries.
+   */
+  supportsGraph: boolean | null;
+  /**
+   * True if `datasourceInstance` supports logs queries.
+   */
+  supportsLogs: boolean | null;
+  /**
+   * True if `datasourceInstance` supports table queries.
+   */
+  supportsTable: boolean | null;
+  /**
+   * Table model that combines all query table results into a single table.
+   */
+  tableResult?: TableModel;
+}
+
+export interface ExploreUrlState {
+  datasource: string;
+  queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense
+  range: RawTimeRange;
+}
+
 export interface HistoryItem {
 export interface HistoryItem {
   ts: number;
   ts: number;
   query: DataQuery;
   query: DataQuery;
@@ -128,6 +298,19 @@ export interface QueryHintGetter {
   (query: DataQuery, results: any[], ...rest: any): QueryHint[];
   (query: DataQuery, results: any[], ...rest: any): QueryHint[];
 }
 }
 
 
+export interface QueryIntervals {
+  interval: string;
+  intervalMs: number;
+}
+
+export interface QueryOptions {
+  interval: string;
+  format: string;
+  hinting?: boolean;
+  instant?: boolean;
+  valueWithRefId?: boolean;
+}
+
 export interface QueryTransaction {
 export interface QueryTransaction {
   id: string;
   id: string;
   done: boolean;
   done: boolean;
@@ -142,6 +325,8 @@ export interface QueryTransaction {
   scanning?: boolean;
   scanning?: boolean;
 }
 }
 
 
+export type RangeScanner = () => RawTimeRange;
+
 export interface TextMatch {
 export interface TextMatch {
   text: string;
   text: string;
   start: number;
   start: number;
@@ -149,38 +334,4 @@ export interface TextMatch {
   end: number;
   end: number;
 }
 }
 
 
-export interface ExploreState {
-  StartPage?: any;
-  datasource: any;
-  datasourceError: any;
-  datasourceLoading: boolean | null;
-  datasourceMissing: boolean;
-  exploreDatasources: DataSourceSelectItem[];
-  graphInterval: number; // in ms
-  graphResult?: any[];
-  history: HistoryItem[];
-  initialDatasource?: string;
-  initialQueries: DataQuery[];
-  logsHighlighterExpressions?: string[];
-  logsResult?: LogsModel;
-  queryTransactions: QueryTransaction[];
-  range: RawTimeRange;
-  scanning?: boolean;
-  scanRange?: RawTimeRange;
-  showingGraph: boolean;
-  showingLogs: boolean;
-  showingStartPage?: boolean;
-  showingTable: boolean;
-  supportsGraph: boolean | null;
-  supportsLogs: boolean | null;
-  supportsTable: boolean | null;
-  tableResult?: TableModel;
-}
-
-export interface ExploreUrlState {
-  datasource: string;
-  queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense
-  range: RawTimeRange;
-}
-
 export type ResultType = 'Graph' | 'Logs' | 'Table';
 export type ResultType = 'Graph' | 'Logs' | 'Table';

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

@@ -19,6 +19,7 @@ import {
 } from './appNotifications';
 } from './appNotifications';
 import { DashboardSearchHit } from './search';
 import { DashboardSearchHit } from './search';
 import { ValidationEvents, ValidationRule } from './form';
 import { ValidationEvents, ValidationRule } from './form';
+import { ExploreState } from './explore';
 export {
 export {
   Team,
   Team,
   TeamsState,
   TeamsState,
@@ -81,6 +82,7 @@ export interface StoreState {
   folder: FolderState;
   folder: FolderState;
   dashboard: DashboardState;
   dashboard: DashboardState;
   dataSources: DataSourcesState;
   dataSources: DataSourcesState;
+  explore: ExploreState;
   users: UsersState;
   users: UsersState;
   organization: OrganizationState;
   organization: OrganizationState;
   appNotifications: AppNotificationsState;
   appNotifications: AppNotificationsState;

+ 1 - 1
public/app/viz/Gauge.test.tsx

@@ -12,7 +12,7 @@ const setup = (propOverrides?: object) => {
   const props: Props = {
   const props: Props = {
     baseColor: BasicGaugeColor.Green,
     baseColor: BasicGaugeColor.Green,
     maxValue: 100,
     maxValue: 100,
-    mappings: [],
+    valueMappings: [],
     minValue: 0,
     minValue: 0,
     prefix: '',
     prefix: '',
     showThresholdMarkers: true,
     showThresholdMarkers: true,

+ 12 - 23
public/app/viz/Gauge.tsx

@@ -1,6 +1,6 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
 import $ from 'jquery';
 import $ from 'jquery';
-import { BasicGaugeColor, Threshold, TimeSeriesVMs, RangeMap, ValueMap, MappingType } from '@grafana/ui';
+import { BasicGaugeColor, Threshold, TimeSeriesVMs, MappingType, ValueMapping } from '@grafana/ui';
 
 
 import config from '../core/config';
 import config from '../core/config';
 import kbn from '../core/utils/kbn';
 import kbn from '../core/utils/kbn';
@@ -9,7 +9,7 @@ export interface Props {
   baseColor: string;
   baseColor: string;
   decimals: number;
   decimals: number;
   height: number;
   height: number;
-  mappings: Array<RangeMap | ValueMap>;
+  valueMappings: ValueMapping[];
   maxValue: number;
   maxValue: number;
   minValue: number;
   minValue: number;
   prefix: string;
   prefix: string;
@@ -29,7 +29,7 @@ export class Gauge extends PureComponent<Props> {
   static defaultProps = {
   static defaultProps = {
     baseColor: BasicGaugeColor.Green,
     baseColor: BasicGaugeColor.Green,
     maxValue: 100,
     maxValue: 100,
-    mappings: [],
+    valueMappings: [],
     minValue: 0,
     minValue: 0,
     prefix: '',
     prefix: '',
     showThresholdMarkers: true,
     showThresholdMarkers: true,
@@ -64,25 +64,22 @@ export class Gauge extends PureComponent<Props> {
       }
       }
     })[0];
     })[0];
 
 
-    return {
-      rangeMap,
-      valueMap,
-    };
+    return { rangeMap, valueMap };
   }
   }
 
 
   formatValue(value) {
   formatValue(value) {
-    const { decimals, mappings, prefix, suffix, unit } = this.props;
+    const { decimals, valueMappings, prefix, suffix, unit } = this.props;
 
 
     const formatFunc = kbn.valueFormats[unit];
     const formatFunc = kbn.valueFormats[unit];
     const formattedValue = formatFunc(value, decimals);
     const formattedValue = formatFunc(value, decimals);
 
 
-    if (mappings.length > 0) {
-      const { rangeMap, valueMap } = this.formatWithMappings(mappings, formattedValue);
+    if (valueMappings.length > 0) {
+      const { rangeMap, valueMap } = this.formatWithMappings(valueMappings, formattedValue);
 
 
       if (valueMap) {
       if (valueMap) {
-        return valueMap;
+        return `${prefix} ${valueMap} ${suffix}`;
       } else if (rangeMap) {
       } else if (rangeMap) {
-        return rangeMap;
+        return `${prefix} ${rangeMap} ${suffix}`;
       }
       }
     }
     }
 
 
@@ -148,10 +145,7 @@ export class Gauge extends PureComponent<Props> {
           color: index === 0 ? threshold.color : thresholds[index].color,
           color: index === 0 ? threshold.color : thresholds[index].color,
         };
         };
       }),
       }),
-      {
-        value: maxValue,
-        color: thresholds.length > 0 ? BasicGaugeColor.Red : baseColor,
-      },
+      { value: maxValue, color: thresholds.length > 0 ? BasicGaugeColor.Red : baseColor },
     ];
     ];
 
 
     const options = {
     const options = {
@@ -184,19 +178,14 @@ export class Gauge extends PureComponent<Props> {
             formatter: () => {
             formatter: () => {
               return this.formatValue(value);
               return this.formatValue(value);
             },
             },
-            font: {
-              size: fontSize,
-              family: '"Helvetica Neue", Helvetica, Arial, sans-serif',
-            },
+            font: { size: fontSize, family: '"Helvetica Neue", Helvetica, Arial, sans-serif' },
           },
           },
           show: true,
           show: true,
         },
         },
       },
       },
     };
     };
 
 
-    const plotSeries = {
-      data: [[0, value]],
-    };
+    const plotSeries = { data: [[0, value]] };
 
 
     try {
     try {
       $.plot(this.canvasElement, [plotSeries], options);
       $.plot(this.canvasElement, [plotSeries], options);

+ 1 - 2
public/sass/_grafana.scss

@@ -1,4 +1,4 @@
- // DEPENDENCIES
+// DEPENDENCIES
 @import '../../node_modules/react-table/react-table.css';
 @import '../../node_modules/react-table/react-table.css';
 
 
 // VENDOR
 // VENDOR
@@ -97,7 +97,6 @@
 @import 'components/add_data_source.scss';
 @import 'components/add_data_source.scss';
 @import 'components/page_loader';
 @import 'components/page_loader';
 @import 'components/toggle_button_group';
 @import 'components/toggle_button_group';
-@import 'components/value-mappings';
 @import 'components/popover-box';
 @import 'components/popover-box';
 
 
 // LOAD @grafana/ui components
 // LOAD @grafana/ui components

+ 1 - 1
public/sass/pages/_explore.scss

@@ -1,5 +1,5 @@
 .explore {
 .explore {
-  width: 100%;
+  flex: 1 1 auto;
 
 
   &-container {
   &-container {
     padding: $dashboard-padding;
     padding: $dashboard-padding;

Some files were not shown because too many files changed in this diff