Browse Source

Merge pull request #14234 from grafana/gauge-value-options

Gauge value options
Johannes Schill 7 years ago
parent
commit
c2aa64595a

+ 1 - 0
public/app/core/components/Label/Label.tsx

@@ -6,6 +6,7 @@ interface Props {
   for?: string;
   children: ReactNode;
   width?: number;
+  className?: string;
 }
 
 export const Label: SFC<Props> = props => {

+ 10 - 0
public/app/core/components/Picker/Unit/UnitGroup.tsx

@@ -14,6 +14,16 @@ export default class UnitGroup extends PureComponent<ExtendedGroupProps, State>
     expanded: false,
   };
 
+  componentDidMount() {
+    if (this.props.selectProps) {
+      const value = this.props.selectProps.value[this.props.selectProps.value.length - 1];
+
+      if (value && this.props.options.some(option => option.value === value)) {
+        this.setState({ expanded: true });
+      }
+    }
+  }
+
   componentDidUpdate(nextProps) {
     if (nextProps.selectProps.inputValue !== '') {
       this.setState({ expanded: true });

+ 14 - 2
public/app/core/components/Picker/Unit/UnitPicker.tsx

@@ -8,11 +8,16 @@ import kbn from '../../../utils/kbn';
 interface Props {
   onSelected: (item: any) => {} | void;
   defaultValue?: string;
+  width?: number;
 }
 
 export default class UnitPicker extends PureComponent<Props> {
+  static defaultProps = {
+    width: 12,
+  };
+
   render() {
-    const { defaultValue, onSelected } = this.props;
+    const { defaultValue, onSelected, width } = this.props;
 
     const unitGroups = kbn.getUnitFormats();
 
@@ -42,6 +47,13 @@ export default class UnitPicker extends PureComponent<Props> {
           overflowY: 'auto',
           position: 'relative',
         } as React.CSSProperties),
+      valueContainer: () =>
+        ({
+          overflow: 'hidden',
+          textOverflow: 'ellipsis',
+          maxWidth: '90px',
+          whiteSpace: 'nowrap',
+        } as React.CSSProperties),
     };
 
     const value = groupOptions.map(group => {
@@ -51,7 +63,7 @@ export default class UnitPicker extends PureComponent<Props> {
     return (
       <Select
         classNamePrefix="gf-form-select-box"
-        className="width-20 gf-form-input--form-dropdown"
+        className={`width-${width} gf-form-input--form-dropdown`}
         defaultValue={value}
         isSearchable={true}
         menuShouldScrollIntoView={false}

+ 9 - 22
public/app/core/components/colorpicker/ColorPicker.tsx

@@ -1,6 +1,5 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
-import $ from 'jquery';
 import Drop from 'tether-drop';
 import { ColorPickerPopover } from './ColorPickerPopover';
 import { react2AngularDirective } from 'app/core/utils/react2angular';
@@ -11,29 +10,17 @@ export interface Props {
 }
 
 export class ColorPicker extends React.Component<Props, any> {
-  pickerElem: any;
+  pickerElem: HTMLElement;
   colorPickerDrop: any;
 
-  constructor(props) {
-    super(props);
-    this.openColorPicker = this.openColorPicker.bind(this);
-    this.closeColorPicker = this.closeColorPicker.bind(this);
-    this.setPickerElem = this.setPickerElem.bind(this);
-    this.onColorSelect = this.onColorSelect.bind(this);
-  }
-
-  setPickerElem(elem) {
-    this.pickerElem = $(elem);
-  }
-
-  openColorPicker() {
+  openColorPicker = () => {
     const dropContent = <ColorPickerPopover color={this.props.color} onColorSelect={this.onColorSelect} />;
 
     const dropContentElem = document.createElement('div');
     ReactDOM.render(dropContent, dropContentElem);
 
     const drop = new Drop({
-      target: this.pickerElem[0],
+      target: this.pickerElem,
       content: dropContentElem,
       position: 'top center',
       classes: 'drop-popover',
@@ -48,23 +35,23 @@ export class ColorPicker extends React.Component<Props, any> {
 
     this.colorPickerDrop = drop;
     this.colorPickerDrop.open();
-  }
+  };
 
-  closeColorPicker() {
+  closeColorPicker = () => {
     setTimeout(() => {
       if (this.colorPickerDrop && this.colorPickerDrop.tether) {
         this.colorPickerDrop.destroy();
       }
     }, 100);
-  }
+  };
 
-  onColorSelect(color) {
+  onColorSelect = color => {
     this.props.onChange(color);
-  }
+  };
 
   render() {
     return (
-      <div className="sp-replacer sp-light" onClick={this.openColorPicker} ref={this.setPickerElem}>
+      <div className="sp-replacer sp-light" onClick={this.openColorPicker} ref={element => (this.pickerElem = element)}>
         <div className="sp-preview">
           <div className="sp-preview-inner" style={{ backgroundColor: this.props.color }} />
         </div>

+ 1 - 1
public/app/features/dashboard/dashgrid/DashboardPanel.tsx

@@ -129,7 +129,7 @@ export class DashboardPanel extends PureComponent<Props, State> {
     const { dashboard, panel } = this.props;
     const { plugin } = this.state;
 
-    return <PanelChrome component={plugin.exports.Panel} panel={panel} dashboard={dashboard} />;
+    return <PanelChrome plugin={plugin} panel={panel} dashboard={dashboard} />;
   }
 
   renderAngularPanel() {

+ 6 - 6
public/app/features/dashboard/dashgrid/PanelChrome.tsx

@@ -1,5 +1,5 @@
 // Libraries
-import React, { ComponentClass, PureComponent } from 'react';
+import React, { PureComponent } from 'react';
 import { AutoSizer } from 'react-virtualized';
 
 // Services
@@ -16,12 +16,12 @@ import { PANEL_HEADER_HEIGHT } from 'app/core/constants';
 // Types
 import { PanelModel } from '../panel_model';
 import { DashboardModel } from '../dashboard_model';
-import { TimeRange, PanelProps } from 'app/types';
+import { PanelPlugin, TimeRange } from 'app/types';
 
 export interface Props {
   panel: PanelModel;
   dashboard: DashboardModel;
-  component: ComponentClass<PanelProps>;
+  plugin: PanelPlugin;
 }
 
 export interface State {
@@ -80,11 +80,11 @@ export class PanelChrome extends PureComponent<Props, State> {
   }
 
   render() {
-    const { panel, dashboard } = this.props;
+    const { panel, dashboard, plugin } = this.props;
     const { refreshCounter, timeRange, timeInfo, renderCounter } = this.state;
 
     const { datasource, targets } = panel;
-    const PanelComponent = this.props.component;
+    const PanelComponent = plugin.exports.Panel;
 
     return (
       <AutoSizer>
@@ -111,7 +111,7 @@ export class PanelChrome extends PureComponent<Props, State> {
                         loading={loading}
                         timeSeries={timeSeries}
                         timeRange={timeRange}
-                        options={panel.getOptions()}
+                        options={panel.getOptions(plugin.exports.PanelDefaults)}
                         width={width}
                         height={height - PANEL_HEADER_HEIGHT}
                         renderCounter={renderCounter}

+ 11 - 5
public/app/features/dashboard/dashgrid/VisualizationTab.tsx

@@ -25,12 +25,18 @@ export class VisualizationTab extends PureComponent<Props> {
   element: HTMLElement;
   angularOptions: AngularComponent;
 
-  constructor(props) {
-    super(props);
-  }
+  getPanelDefaultOptions = () => {
+    const { panel, plugin } = this.props;
+
+    if (plugin.exports.PanelDefaults) {
+      return panel.getOptions(plugin.exports.PanelDefaults.options);
+    }
+
+    return panel.getOptions(plugin.exports.PanelDefaults);
+  };
 
   renderPanelOptions() {
-    const { plugin, panel, angularPanel } = this.props;
+    const { plugin, angularPanel } = this.props;
     const { PanelOptions } = plugin.exports;
 
     if (angularPanel) {
@@ -38,7 +44,7 @@ export class VisualizationTab extends PureComponent<Props> {
     }
 
     if (PanelOptions) {
-      return <PanelOptions options={panel.getOptions()} onChange={this.onPanelOptionsChanged} />;
+      return <PanelOptions options={this.getPanelDefaultOptions()} onChange={this.onPanelOptionsChanged} />;
     } else {
       return <p>Visualization has no options</p>;
     }

+ 2 - 2
public/app/features/dashboard/panel_model.ts

@@ -108,8 +108,8 @@ export class PanelModel {
     _.defaultsDeep(this, _.cloneDeep(defaults));
   }
 
-  getOptions() {
-    return this[this.getOptionsKey()] || {};
+  getOptions(panelDefaults) {
+    return _.defaultsDeep(this[this.getOptionsKey()] || {}, panelDefaults);
   }
 
   updateOptions(options: object) {

+ 27 - 6
public/app/plugins/panel/gauge/GaugeOptions.tsx

@@ -1,14 +1,35 @@
 import React, { PureComponent } from 'react';
-import { PanelOptionsProps } from 'app/types';
+import { Switch } from 'app/core/components/Switch/Switch';
+import { OptionModuleProps } from './module';
 
-interface Props {}
+export default class GaugeOptions extends PureComponent<OptionModuleProps> {
+  toggleThresholdLabels = () =>
+    this.props.onChange({ ...this.props.options, showThresholdLabels: !this.props.options.showThresholdLabels });
+
+  toggleThresholdMarkers = () =>
+    this.props.onChange({ ...this.props.options, showThresholdMarkers: !this.props.options.showThresholdMarkers });
 
-export class GaugeOptions extends PureComponent<PanelOptionsProps<Props>> {
   render() {
+    const { showThresholdLabels, showThresholdMarkers } = this.props.options;
+
     return (
-      <div>
-        <div className="section gf-form-group">
-          <h5 className="page-heading">Draw Modes</h5>
+      <div className="section gf-form-group">
+        <h5 className="page-heading">Gauge</h5>
+        <div className="gf-form-inline">
+          <Switch
+            label="Threshold labels"
+            labelClass="width-10"
+            checked={showThresholdLabels}
+            onChange={this.toggleThresholdLabels}
+          />
+        </div>
+        <div className="gf-form-inline">
+          <Switch
+            label="Threshold markers"
+            labelClass="width-10"
+            checked={showThresholdMarkers}
+            onChange={this.toggleThresholdMarkers}
+          />
         </div>
       </div>
     );

+ 135 - 0
public/app/plugins/panel/gauge/Threshold.test.tsx

@@ -0,0 +1,135 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import Thresholds from './Thresholds';
+import { OptionsProps } from './module';
+import { PanelOptionsProps } from '../../../types';
+
+const setup = (propOverrides?: object) => {
+  const props: PanelOptionsProps<OptionsProps> = {
+    onChange: jest.fn(),
+    options: {} as OptionsProps,
+  };
+
+  Object.assign(props, propOverrides);
+
+  return shallow(<Thresholds {...props} />).instance() as Thresholds;
+};
+
+const thresholds = [
+  { index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
+  { index: 1, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
+  { index: 2, label: 'Max', value: 100, canRemove: false },
+];
+
+describe('Add threshold', () => {
+  it('should add threshold between min and max', () => {
+    const instance = setup();
+
+    instance.onAddThreshold(1);
+
+    expect(instance.state.thresholds).toEqual([
+      { index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
+      { index: 1, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
+      { index: 2, label: 'Max', value: 100, canRemove: false },
+    ]);
+  });
+
+  it('should add threshold between min and added threshold', () => {
+    const instance = setup({
+      options: { thresholds: thresholds },
+    });
+
+    instance.onAddThreshold(1);
+
+    expect(instance.state.thresholds).toEqual([
+      { index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
+      { index: 1, label: '', value: 25, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
+      { index: 2, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
+      { index: 3, label: 'Max', value: 100, canRemove: false },
+    ]);
+  });
+});
+
+describe('Add at index', () => {
+  it('should return 1, no added thresholds', () => {
+    const instance = setup();
+
+    const result = instance.insertAtIndex(1);
+
+    expect(result).toEqual(1);
+  });
+
+  it('should return 1, one added threshold', () => {
+    const instance = setup();
+    instance.state = {
+      thresholds: [
+        { index: 0, label: 'Min', value: 0, canRemove: false },
+        { index: 1, label: '', value: 50, canRemove: true },
+        { index: 2, label: 'Max', value: 100, canRemove: false },
+      ],
+    };
+
+    const result = instance.insertAtIndex(1);
+
+    expect(result).toEqual(1);
+  });
+
+  it('should return 2, two added thresholds', () => {
+    const instance = setup({
+      options: {
+        thresholds: [
+          { index: 0, label: 'Min', value: 0, canRemove: false },
+          { index: 1, label: '', value: 25, canRemove: true },
+          { index: 2, label: '', value: 50, canRemove: true },
+          { index: 3, label: 'Max', value: 100, canRemove: false },
+        ],
+      },
+    });
+
+    const result = instance.insertAtIndex(2);
+
+    expect(result).toEqual(2);
+  });
+
+  it('should return 2, one added threshold', () => {
+    const instance = setup();
+    instance.state = {
+      thresholds: [
+        { index: 0, label: 'Min', value: 0, canRemove: false },
+        { index: 1, label: '', value: 50, canRemove: true },
+        { index: 2, label: 'Max', value: 100, canRemove: false },
+      ],
+    };
+
+    const result = instance.insertAtIndex(2);
+
+    expect(result).toEqual(2);
+  });
+});
+
+describe('change threshold value', () => {
+  it('should update value and resort rows', () => {
+    const instance = setup();
+    const mockThresholds = [
+      { index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
+      { index: 1, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
+      { index: 2, label: '', value: 75, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
+      { index: 3, label: 'Max', value: 100, canRemove: false },
+    ];
+
+    instance.state = {
+      thresholds: mockThresholds,
+    };
+
+    const mockEvent = { target: { value: 78 } };
+
+    instance.onChangeThresholdValue(mockEvent, mockThresholds[1]);
+
+    expect(instance.state.thresholds).toEqual([
+      { index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
+      { index: 1, label: '', value: 78, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
+      { index: 2, label: '', value: 75, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
+      { index: 3, label: 'Max', value: 100, canRemove: false },
+    ]);
+  });
+});

+ 304 - 0
public/app/plugins/panel/gauge/Thresholds.tsx

@@ -0,0 +1,304 @@
+import React, { PureComponent } from 'react';
+import classNames from 'classnames/bind';
+import { ColorPicker } from 'app/core/components/colorpicker/ColorPicker';
+import { OptionModuleProps } from './module';
+import { Threshold } from 'app/types';
+
+interface State {
+  thresholds: Threshold[];
+}
+
+enum BasicGaugeColor {
+  Green = 'rgba(50, 172, 45, 0.97)',
+  Orange = 'rgba(237, 129, 40, 0.89)',
+  Red = 'rgb(212, 74, 58)',
+}
+
+export default class Thresholds extends PureComponent<OptionModuleProps, State> {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      thresholds: this.props.options.thresholds || [
+        { index: 0, label: 'Min', value: 0, canRemove: false, color: BasicGaugeColor.Green },
+        { index: 1, label: 'Max', value: 100, canRemove: false },
+      ],
+    };
+  }
+
+  onAddThreshold = index => {
+    const { thresholds } = this.state;
+
+    const newThresholds = thresholds.map(threshold => {
+      if (threshold.index >= index) {
+        threshold = { ...threshold, index: threshold.index + 1 };
+      }
+
+      return threshold;
+    });
+
+    // Setting value to a value between the new threshold.
+    const value = newThresholds[index].value - (newThresholds[index].value - newThresholds[index - 1].value) / 2;
+
+    this.setState(
+      {
+        thresholds: this.sortThresholds([
+          ...newThresholds,
+          { index: index, label: '', value: value, canRemove: true, color: BasicGaugeColor.Orange },
+        ]),
+      },
+      () => this.updateGauge()
+    );
+  };
+
+  onRemoveThreshold = threshold => {
+    this.setState(
+      prevState => ({
+        thresholds: prevState.thresholds.filter(t => t !== threshold),
+      }),
+      () => this.updateGauge()
+    );
+  };
+
+  onChangeThresholdValue = (event, threshold) => {
+    const { thresholds } = this.state;
+
+    const newThresholds = thresholds.map(t => {
+      if (t === threshold) {
+        t = { ...t, value: event.target.value };
+      }
+
+      return t;
+    });
+
+    this.setState({
+      thresholds: newThresholds,
+    });
+  };
+
+  onChangeThresholdColor = (threshold, color) => {
+    const { thresholds } = this.state;
+
+    const newThresholds = thresholds.map(t => {
+      if (t === threshold) {
+        t = { ...t, color: color };
+      }
+
+      return t;
+    });
+
+    this.setState(
+      {
+        thresholds: newThresholds,
+      },
+      () => this.updateGauge()
+    );
+  };
+
+  onBlur = () => {
+    this.setState(prevState => ({
+      thresholds: this.sortThresholds(prevState.thresholds),
+    }));
+
+    this.updateGauge();
+  };
+
+  updateGauge = () => {
+    this.props.onChange({ ...this.props.options, thresholds: this.state.thresholds });
+  };
+
+  sortThresholds = thresholds => {
+    return thresholds.sort((t1, t2) => {
+      return t1.value - t2.value;
+    });
+  };
+
+  getIndicatorColor = index => {
+    const { thresholds } = this.state;
+
+    if (index === 0) {
+      return thresholds[0].color;
+    }
+
+    return index < thresholds.length ? thresholds[index].color : BasicGaugeColor.Red;
+  };
+
+  renderNoThresholds() {
+    const { thresholds } = this.state;
+
+    const min = thresholds[0];
+    const max = thresholds[1];
+
+    return [
+      <div className="threshold-row threshold-row-min" key="min">
+        <div className="threshold-row-inner">
+          <div className="threshold-row-color">
+            <div className="threshold-row-color-inner">
+              <ColorPicker color={min.color} onChange={color => this.onChangeThresholdColor(min, color)} />
+            </div>
+          </div>
+          <input
+            className="threshold-row-input"
+            onBlur={this.onBlur}
+            onChange={event => this.onChangeThresholdValue(event, min)}
+            value={min.value}
+          />
+          <div className="threshold-row-label">{min.label}</div>
+        </div>
+      </div>,
+      <div className="threshold-row" key="add">
+        <div className="threshold-row-inner">
+          <div onClick={() => this.onAddThreshold(1)} className="threshold-row-add">
+            <i className="fa fa-plus" />
+          </div>
+          <div className="threshold-row-add-label">Add new threshold by clicking the line.</div>
+        </div>
+      </div>,
+      <div className="threshold-row threshold-row-max" key="max">
+        <div className="threshold-row-inner">
+          <div className="threshold-row-color" />
+          <input
+            className="threshold-row-input"
+            onBlur={this.onBlur}
+            onChange={event => this.onChangeThresholdValue(event, max)}
+            value={max.value}
+          />
+          <div className="threshold-row-label">{max.label}</div>
+        </div>
+      </div>,
+    ];
+  }
+
+  renderThresholds() {
+    const { thresholds } = this.state;
+
+    return thresholds.map((threshold, index) => {
+      const rowStyle = classNames({
+        'threshold-row': true,
+        'threshold-row-min': index === 0,
+        'threshold-row-max': index === thresholds.length - 1,
+      });
+
+      return (
+        <div className={rowStyle} 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}
+            />
+            {threshold.canRemove ? (
+              <div onClick={() => this.onRemoveThreshold(threshold)} className="threshold-row-remove">
+                <i className="fa fa-times" />
+              </div>
+            ) : (
+              <div className="threshold-row-label">{threshold.label}</div>
+            )}
+          </div>
+        </div>
+      );
+    });
+  }
+
+  insertAtIndex(index) {
+    const { thresholds } = this.state;
+
+    // If thresholds.length is greater or equal to 3
+    // it means a user has added one threshold
+    if (thresholds.length < 3 || index < 0) {
+      return 1;
+    }
+
+    return index;
+  }
+
+  renderIndicatorSection(index) {
+    const { thresholds } = this.state;
+    const indicators = thresholds.length - 1;
+
+    if (index === 0 || index === thresholds.length) {
+      return (
+        <div
+          key={index}
+          className="indicator-section"
+          style={{
+            height: `calc(100%/${indicators})`,
+          }}
+        >
+          <div
+            onClick={() => this.onAddThreshold(this.insertAtIndex(index - 1))}
+            style={{
+              height: '100%',
+              background: this.getIndicatorColor(index),
+            }}
+          />
+        </div>
+      );
+    }
+
+    return (
+      <div
+        key={index}
+        className="indicator-section"
+        style={{
+          height: `calc(100%/${indicators})`,
+        }}
+      >
+        <div
+          onClick={() => this.onAddThreshold(this.insertAtIndex(index))}
+          style={{
+            height: '50%',
+            background: this.getIndicatorColor(index),
+          }}
+        />
+        <div
+          onClick={() => this.onAddThreshold(this.insertAtIndex(index + 1))}
+          style={{
+            height: `50%`,
+            background: this.getIndicatorColor(index),
+          }}
+        />
+      </div>
+    );
+  }
+
+  renderIndicator() {
+    const { thresholds } = this.state;
+
+    return thresholds.map((t, i) => {
+      if (i <= thresholds.length - 1) {
+        return this.renderIndicatorSection(i);
+      }
+
+      return null;
+    });
+  }
+
+  render() {
+    const { thresholds } = this.state;
+
+    return (
+      <div className="section gf-form-group">
+        <h5 className="page-heading">Thresholds</h5>
+        <div className="thresholds">
+          <div className="color-indicators">{this.renderIndicator()}</div>
+          <div className="threshold-rows">
+            {thresholds.length > 2 ? this.renderThresholds() : this.renderNoThresholds()}
+          </div>
+        </div>
+      </div>
+    );
+  }
+}

+ 80 - 0
public/app/plugins/panel/gauge/ValueOptions.tsx

@@ -0,0 +1,80 @@
+import React, { PureComponent } from 'react';
+import { Label } from 'app/core/components/Label/Label';
+import SimplePicker from 'app/core/components/Picker/SimplePicker';
+import UnitPicker from 'app/core/components/Picker/Unit/UnitPicker';
+import { OptionModuleProps } from './module';
+
+const statOptions = [
+  { value: 'min', text: 'Min' },
+  { value: 'max', text: 'Max' },
+  { value: 'avg', text: 'Average' },
+  { value: 'current', text: 'Current' },
+  { value: 'total', text: 'Total' },
+  { value: 'name', text: 'Name' },
+  { value: 'first', text: 'First' },
+  { value: 'delta', text: 'Delta' },
+  { value: 'diff', text: 'Difference' },
+  { value: 'range', text: 'Range' },
+  { value: 'last_time', text: 'Time of last point' },
+];
+
+const labelWidth = 6;
+
+export default class ValueOptions extends PureComponent<OptionModuleProps> {
+  onUnitChange = unit => this.props.onChange({ ...this.props.options, unit: unit.value });
+
+  onStatChange = stat => this.props.onChange({ ...this.props.options, stat: stat.value });
+
+  onDecimalChange = event => {
+    if (!isNaN(event.target.value)) {
+      this.props.onChange({ ...this.props.options, decimals: event.target.value });
+    }
+  };
+
+  onPrefixChange = event => this.props.onChange({ ...this.props.options, prefix: event.target.value });
+
+  onSuffixChange = event => this.props.onChange({ ...this.props.options, suffix: event.target.value });
+
+  render() {
+    const { stat, unit, decimals, prefix, suffix } = this.props.options;
+
+    return (
+      <div className="section gf-form-group">
+        <h5 className="page-heading">Value</h5>
+        <div className="gf-form-inline">
+          <Label width={labelWidth}>Stat</Label>
+          <SimplePicker
+            width={12}
+            options={statOptions}
+            getOptionLabel={i => i.text}
+            getOptionValue={i => i.value}
+            onSelected={this.onStatChange}
+            value={statOptions.find(option => option.value === stat)}
+          />
+        </div>
+        <div className="gf-form-inline">
+          <Label width={labelWidth}>Unit</Label>
+          <UnitPicker defaultValue={unit} onSelected={value => this.onUnitChange(value)} />
+        </div>
+        <div className="gf-form-inline">
+          <Label width={labelWidth}>Decimals</Label>
+          <input
+            className="gf-form-input width-12"
+            type="number"
+            placeholder="auto"
+            value={decimals || ''}
+            onChange={this.onDecimalChange}
+          />
+        </div>
+        <div className="gf-form-inline">
+          <Label width={labelWidth}>Prefix</Label>
+          <input className="gf-form-input width-12" type="text" value={prefix || ''} onChange={this.onPrefixChange} />
+        </div>
+        <div className="gf-form-inline">
+          <Label width={labelWidth}>Suffix</Label>
+          <input className="gf-form-input width-12" type="text" value={suffix || ''} onChange={this.onSuffixChange} />
+        </div>
+      </div>
+    );
+  }
+}

+ 38 - 31
public/app/plugins/panel/gauge/module.tsx

@@ -1,58 +1,65 @@
 import React, { PureComponent } from 'react';
 import Gauge from 'app/viz/Gauge';
-import { Label } from 'app/core/components/Label/Label';
-import UnitPicker from 'app/core/components/Picker/Unit/UnitPicker';
-import { NullValueMode, PanelOptionsProps, PanelProps } from 'app/types';
+import { NullValueMode, PanelOptionsProps, PanelProps, Threshold } from 'app/types';
 import { getTimeSeriesVMs } from 'app/viz/state/timeSeries';
+import ValueOptions from './ValueOptions';
+import GaugeOptions from './GaugeOptions';
+import Thresholds from './Thresholds';
 
-export interface Options {
-  unit: { label: string; value: string };
+export interface OptionsProps {
+  decimals: number;
+  prefix: string;
+  showThresholdLabels: boolean;
+  showThresholdMarkers: boolean;
+  stat: string;
+  suffix: string;
+  unit: string;
+  thresholds: Threshold[];
 }
 
-interface Props extends PanelProps<Options> {}
+export interface OptionModuleProps {
+  onChange: (item: any) => void;
+  options: OptionsProps;
+}
+
+export const defaultProps = {
+  options: {
+    minValue: 0,
+    maxValue: 100,
+    prefix: '',
+    showThresholdMarkers: true,
+    showThresholdLabels: false,
+    suffix: '',
+  },
+};
+
+interface Props extends PanelProps<OptionsProps> {}
 
-export class GaugePanel extends PureComponent<Props> {
+class GaugePanel extends PureComponent<Props> {
   render() {
     const { timeSeries, width, height } = this.props;
-    const { unit } = this.props.options;
 
     const vmSeries = getTimeSeriesVMs({
       timeSeries: timeSeries,
       nullValueMode: NullValueMode.Ignore,
     });
 
-    return (
-      <Gauge
-        maxValue={100}
-        minValue={0}
-        timeSeries={vmSeries}
-        thresholds={[0, 100]}
-        height={height}
-        width={width}
-        unit={unit}
-      />
-    );
+    return <Gauge timeSeries={vmSeries} {...this.props.options} width={width} height={height} />;
   }
 }
 
-export class GaugeOptions extends PureComponent<PanelOptionsProps<Options>> {
-  onUnitChange = value => {
-    this.props.onChange({ ...this.props.options, unit: value });
-  };
+class Options extends PureComponent<PanelOptionsProps<OptionsProps>> {
+  static defaultProps = defaultProps;
 
   render() {
     return (
       <div>
-        <div className="section gf-form-group">
-          <h5 className="page-heading">Value</h5>
-          <div className="gf-form-inline">
-            <Label width={5}>Unit</Label>
-            <UnitPicker defaultValue={this.props.options.unit.value} onSelected={value => this.onUnitChange(value)} />
-          </div>
-        </div>
+        <ValueOptions onChange={this.props.onChange} options={this.props.options} />
+        <GaugeOptions onChange={this.props.onChange} options={this.props.options} />
+        <Thresholds onChange={this.props.onChange} options={this.props.options} />
       </div>
     );
   }
 }
 
-export { GaugePanel as Panel, GaugeOptions as PanelOptions };
+export { GaugePanel as Panel, Options as PanelOptions, defaultProps as PanelDefaults };

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

@@ -20,7 +20,7 @@ import {
   DataQueryResponse,
   DataQueryOptions,
 } from './series';
-import { PanelProps, PanelOptionsProps } from './panel';
+import { PanelProps, PanelOptionsProps, Threshold } from './panel';
 import { PluginDashboard, PluginMeta, Plugin, PanelPlugin, PluginsState } from './plugins';
 import { Organization, OrganizationState } from './organization';
 import {
@@ -89,6 +89,7 @@ export {
   AppNotificationTimeout,
   DashboardSearchHit,
   UserState,
+  Threshold,
   ValidationEvents,
   ValidationRule,
 };

+ 8 - 0
public/app/types/panel.ts

@@ -28,3 +28,11 @@ export interface PanelMenuItem {
   shortcut?: string;
   subMenu?: PanelMenuItem[];
 }
+
+export interface Threshold {
+  index: number;
+  label: string;
+  value: number;
+  color?: string;
+  canRemove: boolean;
+}

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

@@ -14,6 +14,7 @@ export interface PluginExports {
   PanelCtrl?;
   Panel?: ComponentClass<PanelProps>;
   PanelOptions?: ComponentClass<PanelOptionsProps>;
+  PanelDefaults?: any;
 }
 
 export interface PanelPlugin {

+ 45 - 40
public/app/viz/Gauge.tsx

@@ -1,86 +1,87 @@
 import React, { PureComponent } from 'react';
 import $ from 'jquery';
-import { TimeSeriesVMs } from 'app/types';
+import { Threshold, TimeSeriesVMs } from 'app/types';
 import config from '../core/config';
 import kbn from '../core/utils/kbn';
 
 interface Props {
+  decimals: number;
   timeSeries: TimeSeriesVMs;
-  minValue: number;
-  maxValue: number;
-  showThresholdMarkers?: boolean;
-  thresholds?: number[];
-  showThresholdLables?: boolean;
-  unit: { label: string; value: string };
+  showThresholdMarkers: boolean;
+  thresholds: Threshold[];
+  showThresholdLabels: boolean;
+  unit: string;
   width: number;
   height: number;
+  stat: string;
+  prefix: string;
+  suffix: string;
 }
 
-const colors = ['rgba(50, 172, 45, 0.97)', 'rgba(237, 129, 40, 0.89)', 'rgba(245, 54, 54, 0.9)'];
-
 export class Gauge extends PureComponent<Props> {
-  parentElement: any;
   canvasElement: any;
 
   static defaultProps = {
     minValue: 0,
     maxValue: 100,
+    prefix: '',
     showThresholdMarkers: true,
-    showThresholdLables: false,
-    thresholds: [],
+    showThresholdLabels: false,
+    suffix: '',
+    unit: 'none',
+    thresholds: [
+      { label: 'Min', value: 0, color: 'rgba(50, 172, 45, 0.97)' },
+      { label: 'Max', value: 100, color: 'rgba(245, 54, 54, 0.9)' },
+    ],
   };
 
   componentDidMount() {
     this.draw();
   }
 
-  componentDidUpdate(prevProps: Props) {
+  componentDidUpdate() {
     this.draw();
   }
 
   formatValue(value) {
-    const { unit } = this.props;
+    const { decimals, prefix, suffix, unit } = this.props;
+
+    const formatFunc = kbn.valueFormats[unit];
+
+    if (isNaN(value)) {
+      return '-';
+    }
 
-    const formatFunc = kbn.valueFormats[unit.value];
-    return formatFunc(value);
+    return `${prefix} ${formatFunc(value, decimals)} ${suffix}`;
   }
 
   draw() {
-    const {
-      maxValue,
-      minValue,
-      showThresholdLables,
-      showThresholdMarkers,
-      timeSeries,
-      thresholds,
-      width,
-      height,
-    } = this.props;
+    const { timeSeries, showThresholdLabels, showThresholdMarkers, thresholds, width, height, stat } = this.props;
 
     const dimension = Math.min(width, height * 1.3);
     const backgroundColor = config.bootData.user.lightTheme ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
     const fontColor = config.bootData.user.lightTheme ? 'rgb(38,38,38)' : 'rgb(230,230,230)';
     const fontScale = parseInt('80', 10) / 100;
     const fontSize = Math.min(dimension / 5, 100) * fontScale;
-    const gaugeWidth = Math.min(dimension / 6, 60);
+    const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
+    const gaugeWidth = Math.min(dimension / 6, 60) / gaugeWidthReduceRatio;
     const thresholdMarkersWidth = gaugeWidth / 5;
     const thresholdLabelFontSize = fontSize / 2.5;
 
-    const formattedThresholds = [];
-
-    thresholds.forEach((threshold, index) => {
-      formattedThresholds.push({
-        value: threshold,
-        color: colors[index],
-      });
+    const formattedThresholds = thresholds.map((threshold, index) => {
+      return {
+        value: threshold.value,
+        // Hacky way to get correct color for threshold.
+        color: index === 0 ? threshold.color : thresholds[index - 1].color,
+      };
     });
 
     const options = {
       series: {
         gauges: {
           gauge: {
-            min: minValue,
-            max: maxValue,
+            min: thresholds[0].value,
+            max: thresholds[thresholds.length - 1].value,
             background: { color: backgroundColor },
             border: { color: null },
             shadow: { show: false },
@@ -93,7 +94,7 @@ export class Gauge extends PureComponent<Props> {
           threshold: {
             values: formattedThresholds,
             label: {
-              show: showThresholdLables,
+              show: showThresholdLabels,
               margin: thresholdMarkersWidth + 1,
               font: { size: thresholdLabelFontSize },
             },
@@ -103,7 +104,11 @@ export class Gauge extends PureComponent<Props> {
           value: {
             color: fontColor,
             formatter: () => {
-              return this.formatValue(timeSeries[0].stats.avg);
+              if (timeSeries[0]) {
+                return this.formatValue(timeSeries[0].stats[stat]);
+              }
+
+              return '';
             },
             font: {
               size: fontSize,
@@ -117,7 +122,7 @@ export class Gauge extends PureComponent<Props> {
 
     let value: string | number = 'N/A';
     if (timeSeries.length) {
-      value = timeSeries[0].stats.avg;
+      value = timeSeries[0].stats[stat];
     }
 
     const plotSeries = {
@@ -135,7 +140,7 @@ export class Gauge extends PureComponent<Props> {
     const { height, width } = this.props;
 
     return (
-      <div className="singlestat-panel" ref={element => (this.parentElement = element)}>
+      <div className="singlestat-panel">
         <div
           style={{
             height: `${height * 0.9}px`,

+ 1 - 0
public/sass/_grafana.scss

@@ -103,6 +103,7 @@
 @import 'components/add_data_source.scss';
 @import 'components/page_loader';
 @import 'components/unit-picker';
+@import 'components/thresholds';
 
 // PAGES
 @import 'pages/login';

+ 107 - 0
public/sass/components/_thresholds.scss

@@ -0,0 +1,107 @@
+.thresholds {
+  display: flex;
+  margin-top: 30px;
+}
+
+.threshold-rows {
+  margin-left: 5px;
+}
+
+.threshold-row {
+  display: flex;
+  align-items: center;
+  margin: 5px 0;
+  padding: 5px;
+
+  &::before {
+    font-family: 'FontAwesome';
+    content: '\f0d9';
+    color: $input-label-border-color;
+  }
+}
+
+.threshold-row-inner {
+  border: 1px solid $input-label-border-color;
+  border-radius: $border-radius;
+  display: flex;
+  overflow: hidden;
+  width: 300px;
+  height: 37px;
+}
+
+.threshold-row-color {
+  width: 36px;
+  border-right: 1px solid $input-label-border-color;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: $input-bg;
+}
+
+.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);
+}
+
+.threshold-row-input {
+  padding: 8px 10px;
+  width: 230px;
+}
+
+.threshold-row-label {
+  background-color: $input-label-bg;
+  padding: 5px;
+  width: 36px;
+  display: flex;
+  align-items: center;
+}
+
+.threshold-row-add-label {
+  align-items: center;
+  display: flex;
+  padding: 5px 8px;
+}
+
+.threshold-row-min {
+  margin-top: -22px;
+}
+
+.threshold-row-max {
+  margin-bottom: -22px;
+}
+
+.threshold-row-remove {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 36px;
+  cursor: pointer;
+}
+
+.threshold-row-add {
+  border-right: $border-width solid $input-label-border-color;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 36px;
+  background-color: $green;
+}
+
+.threshold-row-label {
+  border-top-left-radius: 0;
+  border-bottom-left-radius: 0;
+}
+
+.indicator-section {
+  width: 100%;
+  cursor: pointer;
+}
+
+.color-indicators {
+  width: 15px;
+  border-radius: $border-radius;
+  overflow: hidden;
+}

+ 1 - 0
public/vendor/flot/jquery.flot.gauge.js

@@ -588,6 +588,7 @@
             if (!exists) {
                 span = $("<span></span>")
                 span.attr("id", id);
+                span.attr("class", "flot-temp-elem");
                 placeholder.append(span);
             }