Browse Source

Feat: Singlestat panel react progress & refactorings (#16039)

* big value component

* big value component

* editor for font and sparkline

* less logging

* remove sparkline from storybook

* add display value link wrapper

* follow tooltip

* follow tooltip

* merge master

* Just minor refactoring

* use series after last merge

* Refactoring: moving shared singlestat stuff to grafana-ui

* Refactor: Moved final getSingleStatDisplayValues func
Ryan McKinley 6 years ago
parent
commit
c8b2102500
27 changed files with 749 additions and 165 deletions
  1. 37 0
      packages/grafana-ui/src/components/BigValue/BigValue.story.tsx
  2. 38 0
      packages/grafana-ui/src/components/BigValue/BigValue.test.tsx
  3. 134 0
      packages/grafana-ui/src/components/BigValue/BigValue.tsx
  4. 15 0
      packages/grafana-ui/src/components/BigValue/_BigValue.scss
  5. 20 10
      packages/grafana-ui/src/components/SingleStatShared/SingleStatValueEditor.tsx
  6. 122 0
      packages/grafana-ui/src/components/SingleStatShared/shared.ts
  7. 1 0
      packages/grafana-ui/src/components/index.scss
  8. 2 0
      packages/grafana-ui/src/components/index.ts
  9. 1 0
      packages/grafana-ui/src/types/displayValue.ts
  10. 9 3
      public/app/plugins/panel/bargauge/BarGaugePanel.tsx
  11. 9 3
      public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx
  12. 2 3
      public/app/plugins/panel/bargauge/module.tsx
  13. 1 2
      public/app/plugins/panel/bargauge/types.ts
  14. 9 3
      public/app/plugins/panel/gauge/GaugePanel.tsx
  15. 2 2
      public/app/plugins/panel/gauge/GaugePanelEditor.tsx
  16. 3 5
      public/app/plugins/panel/gauge/module.tsx
  17. 1 2
      public/app/plugins/panel/gauge/types.ts
  18. 10 4
      public/app/plugins/panel/piechart/PieChartPanel.tsx
  19. 8 3
      public/app/plugins/panel/piechart/PieChartPanelEditor.tsx
  20. 1 2
      public/app/plugins/panel/piechart/types.ts
  21. 68 0
      public/app/plugins/panel/singlestat2/ColoringEditor.tsx
  22. 67 0
      public/app/plugins/panel/singlestat2/FontSizeEditor.tsx
  23. 16 2
      public/app/plugins/panel/singlestat2/SingleStatEditor.tsx
  24. 112 67
      public/app/plugins/panel/singlestat2/SingleStatPanel.tsx
  25. 33 0
      public/app/plugins/panel/singlestat2/SparklineEditor.tsx
  26. 4 39
      public/app/plugins/panel/singlestat2/module.tsx
  27. 24 15
      public/app/plugins/panel/singlestat2/types.ts

+ 37 - 0
packages/grafana-ui/src/components/BigValue/BigValue.story.tsx

@@ -0,0 +1,37 @@
+import { storiesOf } from '@storybook/react';
+import { number, text } from '@storybook/addon-knobs';
+import { BigValue } from './BigValue';
+import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
+import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
+
+const getKnobs = () => {
+  return {
+    value: text('value', 'Hello'),
+    valueFontSize: number('valueFontSize', 120),
+    prefix: text('prefix', ''),
+  };
+};
+
+const BigValueStories = storiesOf('UI/BigValue', module);
+
+BigValueStories.addDecorator(withCenteredStory);
+
+BigValueStories.add('Singlestat viz', () => {
+  const { value, prefix, valueFontSize } = getKnobs();
+
+  return renderComponentWithTheme(BigValue, {
+    width: 300,
+    height: 250,
+    value: {
+      text: value,
+      numeric: NaN,
+      fontSize: valueFontSize + '%',
+    },
+    prefix: prefix
+      ? {
+          text: prefix,
+          numeric: NaN,
+        }
+      : null,
+  });
+});

+ 38 - 0
packages/grafana-ui/src/components/BigValue/BigValue.test.tsx

@@ -0,0 +1,38 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { BigValue, Props } from './BigValue';
+import { getTheme } from '../../themes/index';
+
+jest.mock('jquery', () => ({
+  plot: jest.fn(),
+}));
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    height: 300,
+    width: 300,
+    value: {
+      text: '25',
+      numeric: 25,
+    },
+    theme: getTheme(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  const wrapper = shallow(<BigValue {...props} />);
+  const instance = wrapper.instance() as BigValue;
+
+  return {
+    instance,
+    wrapper,
+  };
+};
+
+describe('Render BarGauge with basic options', () => {
+  it('should render', () => {
+    const { wrapper } = setup();
+    expect(wrapper).toBeDefined();
+    // expect(wrapper).toMatchSnapshot();
+  });
+});

+ 134 - 0
packages/grafana-ui/src/components/BigValue/BigValue.tsx

@@ -0,0 +1,134 @@
+// Library
+import React, { PureComponent, ReactNode, CSSProperties } from 'react';
+import $ from 'jquery';
+
+// Utils
+import { getColorFromHexRgbOrName } from '../../utils';
+
+// Types
+import { Themeable, DisplayValue } from '../../types';
+
+export interface BigValueSparkline {
+  data: any[][]; // [[number,number]]
+  minX: number;
+  maxX: number;
+  full: boolean; // full height
+  fillColor: string;
+  lineColor: string;
+}
+
+export interface Props extends Themeable {
+  height: number;
+  width: number;
+  value: DisplayValue;
+  prefix?: DisplayValue;
+  suffix?: DisplayValue;
+  sparkline?: BigValueSparkline;
+  backgroundColor?: string;
+}
+
+/*
+ * This visualization is still in POC state, needed more tests & better structure
+ */
+export class BigValue extends PureComponent<Props> {
+  canvasElement: any;
+
+  componentDidMount() {
+    this.draw();
+  }
+
+  componentDidUpdate() {
+    this.draw();
+  }
+
+  draw() {
+    const { sparkline, theme } = this.props;
+
+    if (sparkline && this.canvasElement) {
+      const { data, minX, maxX, fillColor, lineColor } = sparkline;
+
+      const options = {
+        legend: { show: false },
+        series: {
+          lines: {
+            show: true,
+            fill: 1,
+            zero: false,
+            lineWidth: 1,
+            fillColor: getColorFromHexRgbOrName(fillColor, theme.type),
+          },
+        },
+        yaxes: { show: false },
+        xaxis: {
+          show: false,
+          min: minX,
+          max: maxX,
+        },
+        grid: { hoverable: false, show: false },
+      };
+
+      const plotSeries = {
+        data,
+        color: getColorFromHexRgbOrName(lineColor, theme.type),
+      };
+
+      try {
+        $.plot(this.canvasElement, [plotSeries], options);
+      } catch (err) {
+        console.log('sparkline rendering error', err, options);
+      }
+    }
+  }
+
+  renderText = (value?: DisplayValue, padding?: string): ReactNode => {
+    if (!value || !value.text) {
+      return null;
+    }
+    const css: CSSProperties = {};
+    if (padding) {
+      css.padding = padding;
+    }
+    if (value.color) {
+      css.color = value.color;
+    }
+    if (value.fontSize) {
+      css.fontSize = value.fontSize;
+    }
+
+    return <span style={css}>{value.text}</span>;
+  };
+
+  render() {
+    const { height, width, value, prefix, suffix, sparkline, backgroundColor } = this.props;
+
+    const plotCss: CSSProperties = {};
+    plotCss.position = 'absolute';
+
+    if (sparkline) {
+      if (sparkline.full) {
+        plotCss.bottom = '5px';
+        plotCss.left = '-5px';
+        plotCss.width = width - 10 + 'px';
+        const dynamicHeightMargin = height <= 100 ? 5 : Math.round(height / 100) * 15 + 5;
+        plotCss.height = height - dynamicHeightMargin + 'px';
+      } else {
+        plotCss.bottom = '0px';
+        plotCss.left = '-5px';
+        plotCss.width = width - 10 + 'px';
+        plotCss.height = Math.floor(height * 0.25) + 'px';
+      }
+    }
+
+    return (
+      <div className="big-value" style={{ width, height, backgroundColor }}>
+        <span className="big-value__value">
+          {this.renderText(prefix, '0px 2px 0px 0px')}
+          {this.renderText(value)}
+          {this.renderText(suffix)}
+        </span>
+
+        {sparkline && <div style={plotCss} ref={element => (this.canvasElement = element)} />}
+      </div>
+    );
+  }
+}

+ 15 - 0
packages/grafana-ui/src/components/BigValue/_BigValue.scss

@@ -0,0 +1,15 @@
+.big-value {
+  position: relative;
+  display: table;
+}
+
+.big-value__value {
+  line-height: 1;
+  display: table-cell;
+  vertical-align: middle;
+  text-align: center;
+  position: relative;
+  z-index: 1;
+  font-size: 3em;
+  font-weight: $font-weight-semi-bold;
+}

+ 20 - 10
public/app/plugins/panel/singlestat2/SingleStatValueEditor.tsx → packages/grafana-ui/src/components/SingleStatShared/SingleStatValueEditor.tsx

@@ -1,11 +1,19 @@
 // Libraries
-import React, { PureComponent } from 'react';
+import React, { PureComponent, ChangeEvent } from 'react';
 
 // Components
-import { FormField, FormLabel, PanelOptionsGroup, StatsPicker, UnitPicker, StatID } from '@grafana/ui';
+import {
+  FormField,
+  FormLabel,
+  PanelOptionsGroup,
+  StatsPicker,
+  UnitPicker,
+  StatID,
+  SelectOptionItem,
+} from '@grafana/ui';
 
 // Types
-import { SingleStatValueOptions } from './types';
+import { SingleStatValueOptions } from './shared';
 
 const labelWidth = 6;
 
@@ -15,15 +23,15 @@ export interface Props {
 }
 
 export class SingleStatValueEditor extends PureComponent<Props> {
-  onUnitChange = unit => this.props.onChange({ ...this.props.options, unit: unit.value });
+  onUnitChange = (unit: SelectOptionItem) => this.props.onChange({ ...this.props.options, unit: unit.value });
 
-  onStatsChange = stats => {
+  onStatsChange = (stats: string[]) => {
     const stat = stats[0] || StatID.mean;
     this.props.onChange({ ...this.props.options, stat });
   };
 
-  onDecimalChange = event => {
-    if (!isNaN(event.target.value)) {
+  onDecimalChange = (event: ChangeEvent<HTMLInputElement>) => {
+    if (!isNaN(parseInt(event.target.value, 10))) {
       this.props.onChange({
         ...this.props.options,
         decimals: parseInt(event.target.value, 10),
@@ -36,14 +44,16 @@ export class SingleStatValueEditor extends PureComponent<Props> {
     }
   };
 
-  onPrefixChange = event => this.props.onChange({ ...this.props.options, prefix: event.target.value });
-  onSuffixChange = event => this.props.onChange({ ...this.props.options, suffix: event.target.value });
+  onPrefixChange = (event: ChangeEvent<HTMLInputElement>) =>
+    this.props.onChange({ ...this.props.options, prefix: event.target.value });
+  onSuffixChange = (event: ChangeEvent<HTMLInputElement>) =>
+    this.props.onChange({ ...this.props.options, suffix: event.target.value });
 
   render() {
     const { stat, unit, decimals, prefix, suffix } = this.props.options;
 
     let decimalsString = '';
-    if (Number.isFinite(decimals)) {
+    if (decimals !== null && decimals !== undefined && Number.isFinite(decimals as number)) {
       decimalsString = decimals.toString();
     }
 

+ 122 - 0
packages/grafana-ui/src/components/SingleStatShared/shared.ts

@@ -0,0 +1,122 @@
+import cloneDeep from 'lodash/cloneDeep';
+import {
+  ValueMapping,
+  Threshold,
+  VizOrientation,
+  PanelModel,
+  DisplayValue,
+  FieldType,
+  NullValueMode,
+  GrafanaTheme,
+  SeriesData,
+  InterpolateFunction,
+} from '../../types';
+import { getStatsCalculators, calculateStats } from '../../utils/statsCalculator';
+import { getDisplayProcessor } from '../../utils/displayValue';
+export { SingleStatValueEditor } from './SingleStatValueEditor';
+
+export interface SingleStatBaseOptions {
+  valueMappings: ValueMapping[];
+  thresholds: Threshold[];
+  valueOptions: SingleStatValueOptions;
+  orientation: VizOrientation;
+}
+
+export interface SingleStatValueOptions {
+  unit: string;
+  suffix: string;
+  stat: string;
+  prefix: string;
+  decimals?: number | null;
+}
+
+export interface GetSingleStatDisplayValueOptions {
+  data: SeriesData[];
+  theme: GrafanaTheme;
+  valueMappings: ValueMapping[];
+  thresholds: Threshold[];
+  valueOptions: SingleStatValueOptions;
+  replaceVariables: InterpolateFunction;
+}
+
+export const getSingleStatDisplayValues = (options: GetSingleStatDisplayValueOptions): DisplayValue[] => {
+  const { data, replaceVariables, valueOptions } = options;
+  const { unit, decimals, stat } = valueOptions;
+
+  const display = getDisplayProcessor({
+    unit,
+    decimals,
+    mappings: options.valueMappings,
+    thresholds: options.thresholds,
+    prefix: replaceVariables(valueOptions.prefix),
+    suffix: replaceVariables(valueOptions.suffix),
+    theme: options.theme,
+  });
+
+  const values: DisplayValue[] = [];
+
+  for (const series of data) {
+    if (stat === 'name') {
+      values.push(display(series.name));
+    }
+
+    for (let i = 0; i < series.fields.length; i++) {
+      const column = series.fields[i];
+
+      // Show all fields that are not 'time'
+      if (column.type === FieldType.number) {
+        const stats = calculateStats({
+          series,
+          fieldIndex: i,
+          stats: [stat], // The stats to calculate
+          nullValueMode: NullValueMode.Null,
+        });
+        const displayValue = display(stats[stat]);
+        values.push(displayValue);
+      }
+    }
+  }
+
+  if (values.length === 0) {
+    values.push({
+      numeric: 0,
+      text: 'No data',
+    });
+  }
+
+  return values;
+};
+
+const optionsToKeep = ['valueOptions', 'stat', 'maxValue', 'maxValue', 'thresholds', 'valueMappings'];
+
+export const sharedSingleStatOptionsCheck = (
+  options: Partial<SingleStatBaseOptions> | any,
+  prevPluginId: string,
+  prevOptions: any
+) => {
+  for (const k of optionsToKeep) {
+    if (prevOptions.hasOwnProperty(k)) {
+      options[k] = cloneDeep(prevOptions[k]);
+    }
+  }
+  return options;
+};
+
+export const sharedSingleStatMigrationCheck = (panel: PanelModel<SingleStatBaseOptions>) => {
+  const options = panel.options;
+
+  if (!options) {
+    // This happens on the first load or when migrating from angular
+    return {};
+  }
+
+  if (options.valueOptions) {
+    // 6.1 renamed some stats, This makes sure they are up to date
+    // avg -> mean, current -> last, total -> sum
+    const { valueOptions } = options;
+    if (valueOptions && valueOptions.stat) {
+      valueOptions.stat = getStatsCalculators([valueOptions.stat]).map(s => s.id)[0];
+    }
+  }
+  return options;
+};

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

@@ -1,4 +1,5 @@
 @import 'CustomScrollbar/CustomScrollbar';
+@import 'BigValue/BigValue';
 @import 'DeleteButton/DeleteButton';
 @import 'ThresholdsEditor/ThresholdsEditor';
 @import 'Table/Table';

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

@@ -33,9 +33,11 @@ export { StatsPicker } from './StatsPicker/StatsPicker';
 export { Input, InputStatus } from './Input/Input';
 
 // Visualizations
+export { BigValue } from './BigValue/BigValue';
 export { Gauge } from './Gauge/Gauge';
 export { Graph } from './Graph/Graph';
 export { BarGauge } from './BarGauge/BarGauge';
 export { VizRepeater } from './VizRepeater/VizRepeater';
+export * from './SingleStatShared/shared';
 
 export { CallToActionCard } from './CallToActionCard/CallToActionCard';

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

@@ -3,6 +3,7 @@ export interface DisplayValue {
   numeric: number; // Use isNaN to check if it is a real number
   color?: string; // color based on configs or Threshold
   title?: string;
+  fontSize?: string;
 }
 
 export interface DecimalInfo {

+ 9 - 3
public/app/plugins/panel/bargauge/BarGaugePanel.tsx

@@ -2,12 +2,11 @@
 import React, { PureComponent } from 'react';
 
 // Services & Utils
-import { DisplayValue, PanelProps, BarGauge } from '@grafana/ui';
+import { DisplayValue, PanelProps, BarGauge, getSingleStatDisplayValues } from '@grafana/ui';
 import { config } from 'app/core/config';
 
 // Types
 import { BarGaugeOptions } from './types';
-import { getSingleStatValues } from '../singlestat2/SingleStatPanel';
 import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater';
 
 export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
@@ -28,7 +27,14 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
   };
 
   getProcessedValues = (): DisplayValue[] => {
-    return getSingleStatValues(this.props);
+    return getSingleStatDisplayValues({
+      valueMappings: this.props.options.valueMappings,
+      thresholds: this.props.options.thresholds,
+      valueOptions: this.props.options.valueOptions,
+      data: this.props.data,
+      theme: config.theme,
+      replaceVariables: this.props.replaceVariables,
+    });
   };
 
   render() {

+ 9 - 3
public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx

@@ -2,13 +2,19 @@
 import React, { PureComponent } from 'react';
 
 // Components
-import { ThresholdsEditor, ValueMappingsEditor, PanelOptionsGrid, PanelOptionsGroup, FormField } from '@grafana/ui';
+import {
+  ThresholdsEditor,
+  ValueMappingsEditor,
+  PanelOptionsGrid,
+  PanelOptionsGroup,
+  FormField,
+  SingleStatValueOptions,
+  SingleStatValueEditor,
+} from '@grafana/ui';
 
 // Types
 import { FormLabel, PanelEditorProps, Threshold, Select, ValueMapping } from '@grafana/ui';
 import { BarGaugeOptions, orientationOptions, displayModes } from './types';
-import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor';
-import { SingleStatValueOptions } from '../singlestat2/types';
 
 export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGaugeOptions>> {
   onThresholdsChanged = (thresholds: Threshold[]) =>

+ 2 - 3
public/app/plugins/panel/bargauge/module.tsx

@@ -1,11 +1,10 @@
-import { ReactPanelPlugin } from '@grafana/ui';
+import { ReactPanelPlugin, sharedSingleStatOptionsCheck } from '@grafana/ui';
 
 import { BarGaugePanel } from './BarGaugePanel';
 import { BarGaugePanelEditor } from './BarGaugePanelEditor';
 import { BarGaugeOptions, defaults } from './types';
-import { singleStatBaseOptionsCheck } from '../singlestat2/module';
 
 export const reactPanel = new ReactPanelPlugin<BarGaugeOptions>(BarGaugePanel)
   .setDefaults(defaults)
   .setEditor(BarGaugePanelEditor)
-  .setPanelChangeHandler(singleStatBaseOptionsCheck);
+  .setPanelChangeHandler(sharedSingleStatOptionsCheck);

+ 1 - 2
public/app/plugins/panel/bargauge/types.ts

@@ -1,5 +1,4 @@
-import { VizOrientation, SelectOptionItem, StatID } from '@grafana/ui';
-import { SingleStatBaseOptions } from '../singlestat2/types';
+import { VizOrientation, SelectOptionItem, StatID, SingleStatBaseOptions } from '@grafana/ui';
 
 export interface BarGaugeOptions extends SingleStatBaseOptions {
   minValue: number;

+ 9 - 3
public/app/plugins/panel/gauge/GaugePanel.tsx

@@ -9,8 +9,7 @@ import { Gauge } from '@grafana/ui';
 
 // Types
 import { GaugeOptions } from './types';
-import { DisplayValue, PanelProps } from '@grafana/ui';
-import { getSingleStatValues } from '../singlestat2/SingleStatPanel';
+import { DisplayValue, PanelProps, getSingleStatDisplayValues } from '@grafana/ui';
 import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater';
 
 export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
@@ -33,7 +32,14 @@ export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
   };
 
   getProcessedValues = (): DisplayValue[] => {
-    return getSingleStatValues(this.props);
+    return getSingleStatDisplayValues({
+      valueMappings: this.props.options.valueMappings,
+      thresholds: this.props.options.thresholds,
+      valueOptions: this.props.options.valueOptions,
+      data: this.props.data,
+      theme: config.theme,
+      replaceVariables: this.props.replaceVariables,
+    });
   };
 
   render() {

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

@@ -7,12 +7,12 @@ import {
   PanelOptionsGrid,
   ValueMappingsEditor,
   ValueMapping,
+  SingleStatValueOptions,
+  SingleStatValueEditor,
 } from '@grafana/ui';
 
 import { GaugeOptionsBox } from './GaugeOptionsBox';
 import { GaugeOptions } from './types';
-import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor';
-import { SingleStatValueOptions } from '../singlestat2/types';
 
 export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOptions>> {
   onThresholdsChanged = (thresholds: Threshold[]) =>

+ 3 - 5
public/app/plugins/panel/gauge/module.tsx

@@ -1,12 +1,10 @@
-import { ReactPanelPlugin } from '@grafana/ui';
-
+import { ReactPanelPlugin, sharedSingleStatMigrationCheck, sharedSingleStatOptionsCheck } from '@grafana/ui';
 import { GaugePanelEditor } from './GaugePanelEditor';
 import { GaugePanel } from './GaugePanel';
 import { GaugeOptions, defaults } from './types';
-import { singleStatBaseOptionsCheck, singleStatMigrationCheck } from '../singlestat2/module';
 
 export const reactPanel = new ReactPanelPlugin<GaugeOptions>(GaugePanel)
   .setDefaults(defaults)
   .setEditor(GaugePanelEditor)
-  .setPanelChangeHandler(singleStatBaseOptionsCheck)
-  .setMigrationHandler(singleStatMigrationCheck);
+  .setPanelChangeHandler(sharedSingleStatOptionsCheck)
+  .setMigrationHandler(sharedSingleStatMigrationCheck);

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

@@ -1,5 +1,4 @@
-import { SingleStatBaseOptions } from '../singlestat2/types';
-import { VizOrientation, StatID } from '@grafana/ui';
+import { VizOrientation, StatID, SingleStatBaseOptions } from '@grafana/ui';
 
 export interface GaugeOptions extends SingleStatBaseOptions {
   maxValue: number;

+ 10 - 4
public/app/plugins/panel/piechart/PieChartPanel.tsx

@@ -5,20 +5,26 @@ import React, { PureComponent } from 'react';
 import { config } from 'app/core/config';
 
 // Components
-import { PieChart } from '@grafana/ui';
+import { PieChart, getSingleStatDisplayValues } from '@grafana/ui';
 
 // Types
 import { PieChartOptions } from './types';
 import { PanelProps } from '@grafana/ui/src/types';
-import { getSingleStatValues } from '../singlestat2/SingleStatPanel';
 
 interface Props extends PanelProps<PieChartOptions> {}
 
 export class PieChartPanel extends PureComponent<Props> {
   render() {
-    const { width, height, options } = this.props;
+    const { width, height, options, data, replaceVariables } = this.props;
 
-    const values = getSingleStatValues(this.props);
+    const values = getSingleStatDisplayValues({
+      valueMappings: options.valueMappings,
+      thresholds: options.thresholds,
+      valueOptions: options.valueOptions,
+      data: data,
+      theme: config.theme,
+      replaceVariables: replaceVariables,
+    });
 
     return (
       <PieChart

+ 8 - 3
public/app/plugins/panel/piechart/PieChartPanelEditor.tsx

@@ -1,10 +1,15 @@
 import React, { PureComponent } from 'react';
-import { PanelEditorProps, PanelOptionsGrid, ValueMappingsEditor, ValueMapping } from '@grafana/ui';
+import {
+  PanelEditorProps,
+  PanelOptionsGrid,
+  ValueMappingsEditor,
+  ValueMapping,
+  SingleStatValueOptions,
+  SingleStatValueEditor,
+} from '@grafana/ui';
 
 import { PieChartOptionsBox } from './PieChartOptionsBox';
 import { PieChartOptions } from './types';
-import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor';
-import { SingleStatValueOptions } from '../singlestat2/types';
 
 export class PieChartPanelEditor extends PureComponent<PanelEditorProps<PieChartOptions>> {
   onValueMappingsChanged = (valueMappings: ValueMapping[]) =>

+ 1 - 2
public/app/plugins/panel/piechart/types.ts

@@ -1,5 +1,4 @@
-import { PieChartType, StatID, VizOrientation } from '@grafana/ui';
-import { SingleStatBaseOptions } from '../singlestat2/types';
+import { PieChartType, StatID, VizOrientation, SingleStatBaseOptions } from '@grafana/ui';
 
 export interface PieChartOptions extends SingleStatBaseOptions {
   pieType: PieChartType;

+ 68 - 0
public/app/plugins/panel/singlestat2/ColoringEditor.tsx

@@ -0,0 +1,68 @@
+// Libraries
+import React, { PureComponent } from 'react';
+
+// Components
+import { Switch, PanelOptionsGroup } from '@grafana/ui';
+
+// Types
+import { SingleStatOptions } from './types';
+
+const labelWidth = 6;
+
+export interface Props {
+  options: SingleStatOptions;
+  onChange: (options: SingleStatOptions) => void;
+}
+
+// colorBackground?: boolean;
+// colorValue?: boolean;
+// colorPrefix?: boolean;
+// colorPostfix?: boolean;
+
+export class ColoringEditor extends PureComponent<Props> {
+  onToggleColorBackground = () =>
+    this.props.onChange({ ...this.props.options, colorBackground: !this.props.options.colorBackground });
+
+  onToggleColorValue = () => this.props.onChange({ ...this.props.options, colorValue: !this.props.options.colorValue });
+
+  onToggleColorPrefix = () =>
+    this.props.onChange({ ...this.props.options, colorPrefix: !this.props.options.colorPrefix });
+
+  onToggleColorPostfix = () =>
+    this.props.onChange({ ...this.props.options, colorPostfix: !this.props.options.colorPostfix });
+
+  render() {
+    const { colorBackground, colorValue, colorPrefix, colorPostfix } = this.props.options;
+
+    return (
+      <PanelOptionsGroup title="Coloring">
+        <Switch
+          label="Background"
+          labelClass={`width-${labelWidth}`}
+          checked={colorBackground}
+          onChange={this.onToggleColorBackground}
+        />
+
+        <Switch
+          label="Value"
+          labelClass={`width-${labelWidth}`}
+          checked={colorValue}
+          onChange={this.onToggleColorValue}
+        />
+
+        <Switch
+          label="Prefix"
+          labelClass={`width-${labelWidth}`}
+          checked={colorPrefix}
+          onChange={this.onToggleColorPrefix}
+        />
+        <Switch
+          label="Postfix"
+          labelClass={`width-${labelWidth}`}
+          checked={colorPostfix}
+          onChange={this.onToggleColorPostfix}
+        />
+      </PanelOptionsGroup>
+    );
+  }
+}

+ 67 - 0
public/app/plugins/panel/singlestat2/FontSizeEditor.tsx

@@ -0,0 +1,67 @@
+// Libraries
+import React, { PureComponent } from 'react';
+
+// Components
+import { FormLabel, Select, PanelOptionsGroup, SelectOptionItem } from '@grafana/ui';
+
+// Types
+import { SingleStatOptions } from './types';
+
+const labelWidth = 6;
+
+export interface Props {
+  options: SingleStatOptions;
+  onChange: (options: SingleStatOptions) => void;
+}
+
+const percents = ['20%', '30%', '50%', '70%', '80%', '100%', '110%', '120%', '150%', '170%', '200%'];
+const fontSizeOptions = percents.map(v => {
+  return { value: v, label: v };
+});
+
+export class FontSizeEditor extends PureComponent<Props> {
+  setPrefixFontSize = (v: SelectOptionItem) => this.props.onChange({ ...this.props.options, prefixFontSize: v.value });
+
+  setValueFontSize = (v: SelectOptionItem) => this.props.onChange({ ...this.props.options, valueFontSize: v.value });
+
+  setPostfixFontSize = (v: SelectOptionItem) =>
+    this.props.onChange({ ...this.props.options, postfixFontSize: v.value });
+
+  render() {
+    const { prefixFontSize, valueFontSize, postfixFontSize } = this.props.options;
+
+    return (
+      <PanelOptionsGroup title="Font Size">
+        <div className="gf-form">
+          <FormLabel width={labelWidth}>Prefix</FormLabel>
+          <Select
+            width={12}
+            options={fontSizeOptions}
+            onChange={this.setPrefixFontSize}
+            value={fontSizeOptions.find(option => option.value === prefixFontSize)}
+          />
+        </div>
+
+        <div className="gf-form">
+          <FormLabel width={labelWidth}>Value</FormLabel>
+          <Select
+            width={12}
+            options={fontSizeOptions}
+            onChange={this.setValueFontSize}
+            value={fontSizeOptions.find(option => option.value === valueFontSize)}
+          />
+        </div>
+
+        <div className="gf-form">
+          <FormLabel width={labelWidth}>Postfix</FormLabel>
+          <Select
+            width={12}
+            options={fontSizeOptions}
+            onChange={this.setPostfixFontSize}
+            value={fontSizeOptions.find(option => option.value === postfixFontSize)}
+          />
+        </div>
+      </PanelOptionsGroup>
+    );
+  }
+}

+ 16 - 2
public/app/plugins/panel/singlestat2/SingleStatEditor.tsx

@@ -7,10 +7,14 @@ import {
   PanelOptionsGrid,
   ValueMappingsEditor,
   ValueMapping,
+  SingleStatValueOptions,
+  SingleStatValueEditor,
 } from '@grafana/ui';
 
-import { SingleStatOptions, SingleStatValueOptions } from './types';
-import { SingleStatValueEditor } from './SingleStatValueEditor';
+import { SingleStatOptions, SparklineOptions } from './types';
+import { ColoringEditor } from './ColoringEditor';
+import { FontSizeEditor } from './FontSizeEditor';
+import { SparklineEditor } from './SparklineEditor';
 
 export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatOptions>> {
   onThresholdsChanged = (thresholds: Threshold[]) =>
@@ -31,6 +35,12 @@ export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatO
       valueOptions,
     });
 
+  onSparklineChanged = (sparkline: SparklineOptions) =>
+    this.props.onOptionsChange({
+      ...this.props.options,
+      sparkline,
+    });
+
   render() {
     const { options } = this.props;
 
@@ -38,6 +48,10 @@ export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatO
       <>
         <PanelOptionsGrid>
           <SingleStatValueEditor onChange={this.onValueOptionsChanged} options={options.valueOptions} />
+          <FontSizeEditor options={options} onChange={this.props.onOptionsChange} />
+          <ColoringEditor options={options} onChange={this.props.onOptionsChange} />
+          <SparklineEditor options={options.sparkline} onChange={this.onSparklineChanged} />
+
           <ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={options.thresholds} />
         </PanelOptionsGrid>
 

+ 112 - 67
public/app/plugins/panel/singlestat2/SingleStatPanel.tsx

@@ -1,82 +1,127 @@
 // Libraries
-import React, { PureComponent, CSSProperties } from 'react';
+import React, { PureComponent } from 'react';
 
-// Types
-import { SingleStatOptions, SingleStatBaseOptions } from './types';
-
-import { DisplayValue, PanelProps, NullValueMode, FieldType, calculateStats } from '@grafana/ui';
+// Utils & Services
 import { config } from 'app/core/config';
-import { getDisplayProcessor } from '@grafana/ui';
+import { getFlotPairs } from '@grafana/ui/src/utils/flotPairs';
+
+// Components
 import { ProcessedValuesRepeater } from './ProcessedValuesRepeater';
 
-export const getSingleStatValues = (props: PanelProps<SingleStatBaseOptions>): DisplayValue[] => {
-  const { data, replaceVariables, options } = props;
-  const { valueOptions, valueMappings } = options;
-  const { unit, decimals, stat } = valueOptions;
-
-  const display = getDisplayProcessor({
-    unit,
-    decimals,
-    mappings: valueMappings,
-    thresholds: options.thresholds,
-    prefix: replaceVariables(valueOptions.prefix),
-    suffix: replaceVariables(valueOptions.suffix),
-    theme: config.theme,
-  });
-
-  const values: DisplayValue[] = [];
-
-  for (const series of data) {
-    if (stat === 'name') {
-      values.push(display(series.name));
-    }
+// Types
+import { SingleStatOptions } from './types';
+import { BigValueSparkline, BigValue } from '@grafana/ui/src/components/BigValue/BigValue';
+import {
+  DisplayValue,
+  PanelProps,
+  getDisplayProcessor,
+  NullValueMode,
+  FieldType,
+  calculateStats,
+  getFirstTimeField,
+} from '@grafana/ui';
 
-    for (let i = 0; i < series.fields.length; i++) {
-      const column = series.fields[i];
-
-      // Show all columns that are not 'time'
-      if (column.type === FieldType.number) {
-        const stats = calculateStats({
-          series,
-          fieldIndex: i,
-          stats: [stat], // The stats to calculate
-          nullValueMode: NullValueMode.Null,
-        });
-        const displayValue = display(stats[stat]);
-        values.push(displayValue);
-      }
-    }
-  }
+interface SingleStatDisplay {
+  value: DisplayValue;
+  prefix?: DisplayValue;
+  suffix?: DisplayValue;
+  sparkline?: BigValueSparkline;
+  backgroundColor?: string;
+}
+
+export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>> {
+  renderValue = (value: SingleStatDisplay, width: number, height: number): JSX.Element => {
+    return <BigValue {...value} width={width} height={height} theme={config.theme} />;
+  };
 
-  if (values.length === 0) {
-    values.push({
-      numeric: 0,
-      text: 'No data',
+  getProcessedValues = (): SingleStatDisplay[] => {
+    const { data, replaceVariables, options, timeRange } = this.props;
+    const { valueOptions, valueMappings } = options;
+
+    const display = getDisplayProcessor({
+      unit: valueOptions.unit,
+      decimals: valueOptions.decimals,
+      mappings: valueMappings,
+      thresholds: options.thresholds,
+      theme: config.theme,
     });
-  }
 
-  return values;
-};
+    const { colorBackground, colorValue, colorPrefix, colorPostfix, sparkline } = options;
+    const { stat } = valueOptions;
 
-export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>> {
-  renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => {
-    const style: CSSProperties = {};
-    style.margin = '0 auto';
-    style.fontSize = '250%';
-    style.textAlign = 'center';
-    if (value.color) {
-      style.color = value.color;
-    }
+    const values: SingleStatDisplay[] = [];
 
-    return (
-      <div style={{ width, height }}>
-        <div style={style}>{value.text}</div>
-      </div>
-    );
-  };
+    for (const series of data) {
+      const timeColumn = sparkline.show ? getFirstTimeField(series) : -1;
+
+      for (let i = 0; i < series.fields.length; i++) {
+        const column = series.fields[i];
+
+        // Show all fields that are not 'time'
+        if (column.type === FieldType.number) {
+          const stats = calculateStats({
+            series,
+            fieldIndex: i,
+            stats: [stat], // The stats to calculate
+            nullValueMode: NullValueMode.Null,
+          });
+
+          const v: SingleStatDisplay = {
+            value: display(stats[stat]),
+          };
+
+          const color = v.value.color;
+          if (!colorValue) {
+            delete v.value.color;
+          }
+
+          if (colorBackground) {
+            v.backgroundColor = color;
+          }
+
+          if (options.valueFontSize) {
+            v.value.fontSize = options.valueFontSize;
+          }
+
+          if (valueOptions.prefix) {
+            v.prefix = {
+              text: replaceVariables(valueOptions.prefix),
+              numeric: NaN,
+              color: colorPrefix ? color : null,
+              fontSize: options.prefixFontSize,
+            };
+          }
+          if (valueOptions.suffix) {
+            v.suffix = {
+              text: replaceVariables(valueOptions.suffix),
+              numeric: NaN,
+              color: colorPostfix ? color : null,
+              fontSize: options.postfixFontSize,
+            };
+          }
+
+          if (sparkline.show && timeColumn >= 0) {
+            const points = getFlotPairs({
+              series,
+              xIndex: timeColumn,
+              yIndex: i,
+              nullValueMode: NullValueMode.Null,
+            });
+
+            v.sparkline = {
+              ...sparkline,
+              data: points,
+              minX: timeRange.from.valueOf(),
+              maxX: timeRange.to.valueOf(),
+            };
+          }
+
+          values.push(v);
+        }
+      }
+    }
 
-  getProcessedValues = (): DisplayValue[] => {
-    return getSingleStatValues(this.props);
+    return values;
   };
 
   render() {

+ 33 - 0
public/app/plugins/panel/singlestat2/SparklineEditor.tsx

@@ -0,0 +1,33 @@
+// Libraries
+import React, { PureComponent } from 'react';
+
+// Components
+import { Switch, PanelOptionsGroup } from '@grafana/ui';
+
+// Types
+import { SparklineOptions } from './types';
+
+const labelWidth = 6;
+
+export interface Props {
+  options: SparklineOptions;
+  onChange: (options: SparklineOptions) => void;
+}
+
+export class SparklineEditor extends PureComponent<Props> {
+  onToggleShow = () => this.props.onChange({ ...this.props.options, show: !this.props.options.show });
+
+  onToggleFull = () => this.props.onChange({ ...this.props.options, full: !this.props.options.full });
+
+  render() {
+    const { show, full } = this.props.options;
+
+    return (
+      <PanelOptionsGroup title="Sparkline">
+        <Switch label="Show" labelClass={`width-${labelWidth}`} checked={show} onChange={this.onToggleShow} />
+
+        <Switch label="Full Height" labelClass={`width-${labelWidth}`} checked={full} onChange={this.onToggleFull} />
+      </PanelOptionsGroup>
+    );
+  }
+}

+ 4 - 39
public/app/plugins/panel/singlestat2/module.tsx

@@ -1,45 +1,10 @@
-import { ReactPanelPlugin, getStatsCalculators, PanelModel } from '@grafana/ui';
-import { SingleStatOptions, defaults, SingleStatBaseOptions } from './types';
+import { ReactPanelPlugin, sharedSingleStatMigrationCheck, sharedSingleStatOptionsCheck } from '@grafana/ui';
+import { SingleStatOptions, defaults } from './types';
 import { SingleStatPanel } from './SingleStatPanel';
-import cloneDeep from 'lodash/cloneDeep';
 import { SingleStatEditor } from './SingleStatEditor';
 
-const optionsToKeep = ['valueOptions', 'stat', 'maxValue', 'maxValue', 'thresholds', 'valueMappings'];
-
-export const singleStatBaseOptionsCheck = (
-  options: Partial<SingleStatBaseOptions>,
-  prevPluginId: string,
-  prevOptions: any
-) => {
-  for (const k of optionsToKeep) {
-    if (prevOptions.hasOwnProperty(k)) {
-      options[k] = cloneDeep(prevOptions[k]);
-    }
-  }
-  return options;
-};
-
-export const singleStatMigrationCheck = (panel: PanelModel<SingleStatOptions>) => {
-  const options = panel.options;
-
-  if (!options) {
-    // This happens on the first load or when migrating from angular
-    return {};
-  }
-
-  if (options.valueOptions) {
-    // 6.1 renamed some stats, This makes sure they are up to date
-    // avg -> mean, current -> last, total -> sum
-    const { valueOptions } = options;
-    if (valueOptions && valueOptions.stat) {
-      valueOptions.stat = getStatsCalculators([valueOptions.stat]).map(s => s.id)[0];
-    }
-  }
-  return options;
-};
-
 export const reactPanel = new ReactPanelPlugin<SingleStatOptions>(SingleStatPanel)
   .setDefaults(defaults)
   .setEditor(SingleStatEditor)
-  .setPanelChangeHandler(singleStatMigrationCheck)
-  .setMigrationHandler(singleStatMigrationCheck);
+  .setPanelChangeHandler(sharedSingleStatOptionsCheck)
+  .setMigrationHandler(sharedSingleStatMigrationCheck);

+ 24 - 15
public/app/plugins/panel/singlestat2/types.ts

@@ -1,25 +1,34 @@
-import { VizOrientation, ValueMapping, Threshold, StatID } from '@grafana/ui';
+import { VizOrientation, StatID, SingleStatBaseOptions } from '@grafana/ui';
 
-export interface SingleStatBaseOptions {
-  valueMappings: ValueMapping[];
-  thresholds: Threshold[];
-  valueOptions: SingleStatValueOptions;
-  orientation: VizOrientation;
-}
-
-export interface SingleStatValueOptions {
-  unit: string;
-  suffix: string;
-  stat: string;
-  prefix: string;
-  decimals?: number | null;
+export interface SparklineOptions {
+  show: boolean;
+  full: boolean; // full height
+  fillColor: string;
+  lineColor: string;
 }
 
+// Structure copied from angular
 export interface SingleStatOptions extends SingleStatBaseOptions {
-  // TODO, fill in with options from angular
+  prefixFontSize?: string;
+  valueFontSize?: string;
+  postfixFontSize?: string;
+
+  colorBackground?: boolean;
+  colorValue?: boolean;
+  colorPrefix?: boolean;
+  colorPostfix?: boolean;
+
+  sparkline: SparklineOptions;
 }
 
 export const defaults: SingleStatOptions = {
+  sparkline: {
+    show: true,
+    full: false,
+    lineColor: 'rgb(31, 120, 193)',
+    fillColor: 'rgba(31, 118, 189, 0.18)',
+  },
+
   valueOptions: {
     prefix: '',
     suffix: '',