Explorar o código

Merge pull request #15938 from ryantxu/table-reducer

Calculate stats on TableData
Torkel Ödegaard %!s(int64=6) %!d(string=hai) anos
pai
achega
0f0f76b602

+ 79 - 0
packages/grafana-ui/src/components/StatsPicker/StatsPicker.story.tsx

@@ -0,0 +1,79 @@
+import React, { PureComponent } from 'react';
+
+import { storiesOf } from '@storybook/react';
+import { action } from '@storybook/addon-actions';
+import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
+import { StatsPicker } from './StatsPicker';
+import { text, boolean } from '@storybook/addon-knobs';
+
+const getKnobs = () => {
+  return {
+    placeholder: text('Placeholder Text', ''),
+    defaultStat: text('Default Stat', ''),
+    allowMultiple: boolean('Allow Multiple', false),
+    initialStats: text('Initial Stats', ''),
+  };
+};
+
+interface State {
+  stats: string[];
+}
+
+export class WrapperWithState extends PureComponent<any, State> {
+  constructor(props: any) {
+    super(props);
+    this.state = {
+      stats: this.toStatsArray(props.initialReducers),
+    };
+  }
+
+  toStatsArray = (txt: string): string[] => {
+    if (!txt) {
+      return [];
+    }
+    return txt.split(',').map(v => v.trim());
+  };
+
+  componentDidUpdate(prevProps: any) {
+    const { initialReducers } = this.props;
+    if (initialReducers !== prevProps.initialReducers) {
+      console.log('Changing initial reducers');
+      this.setState({ stats: this.toStatsArray(initialReducers) });
+    }
+  }
+
+  render() {
+    const { placeholder, defaultStat, allowMultiple } = this.props;
+    const { stats } = this.state;
+
+    return (
+      <StatsPicker
+        placeholder={placeholder}
+        defaultStat={defaultStat}
+        allowMultiple={allowMultiple}
+        stats={stats}
+        onChange={(stats: string[]) => {
+          action('Picked:')(stats);
+          this.setState({ stats });
+        }}
+      />
+    );
+  }
+}
+
+const story = storiesOf('UI/StatsPicker', module);
+story.addDecorator(withCenteredStory);
+story.add('picker', () => {
+  const { placeholder, defaultStat, allowMultiple, initialStats } = getKnobs();
+
+  return (
+    <div>
+      <WrapperWithState
+        placeholder={placeholder}
+        defaultStat={defaultStat}
+        allowMultiple={allowMultiple}
+        initialStats={initialStats}
+      />
+    </div>
+  );
+});

+ 91 - 0
packages/grafana-ui/src/components/StatsPicker/StatsPicker.tsx

@@ -0,0 +1,91 @@
+import React, { PureComponent } from 'react';
+
+import isArray from 'lodash/isArray';
+import difference from 'lodash/difference';
+
+import { Select } from '../index';
+
+import { getStatsCalculators } from '../../utils/statsCalculator';
+import { SelectOptionItem } from '../Select/Select';
+
+interface Props {
+  placeholder?: string;
+  onChange: (stats: string[]) => void;
+  stats: string[];
+  width?: number;
+  allowMultiple?: boolean;
+  defaultStat?: string;
+}
+
+export class StatsPicker extends PureComponent<Props> {
+  static defaultProps = {
+    width: 12,
+    allowMultiple: false,
+  };
+
+  componentDidMount() {
+    this.checkInput();
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    this.checkInput();
+  }
+
+  checkInput = () => {
+    const { stats, allowMultiple, defaultStat, onChange } = this.props;
+
+    const current = getStatsCalculators(stats);
+    if (current.length !== stats.length) {
+      const found = current.map(v => v.id);
+      const notFound = difference(stats, found);
+      console.warn('Unknown stats', notFound, stats);
+      onChange(current.map(stat => stat.id));
+    }
+
+    // Make sure there is only one
+    if (!allowMultiple && stats.length > 1) {
+      console.warn('Removing extra stat', stats);
+      onChange([stats[0]]);
+    }
+
+    // Set the reducer from callback
+    if (defaultStat && stats.length < 1) {
+      onChange([defaultStat]);
+    }
+  };
+
+  onSelectionChange = (item: SelectOptionItem) => {
+    const { onChange } = this.props;
+    if (isArray(item)) {
+      onChange(item.map(v => v.value));
+    } else {
+      onChange([item.value]);
+    }
+  };
+
+  render() {
+    const { width, stats, allowMultiple, defaultStat, placeholder } = this.props;
+    const options = getStatsCalculators().map(s => {
+      return {
+        value: s.id,
+        label: s.name,
+        description: s.description,
+      };
+    });
+
+    const value: SelectOptionItem[] = options.filter(option => stats.find(stat => option.value === stat));
+
+    return (
+      <Select
+        width={width}
+        value={value}
+        isClearable={!defaultStat}
+        isMulti={allowMultiple}
+        isSearchable={true}
+        options={options}
+        placeholder={placeholder}
+        onChange={this.onSelectionChange}
+      />
+    );
+  }
+}

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

@@ -27,6 +27,7 @@ export { Switch } from './Switch/Switch';
 export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
 export { PieChart, PieChartDataPoint, PieChartType } from './PieChart/PieChart';
 export { UnitPicker } from './UnitPicker/UnitPicker';
+export { StatsPicker } from './StatsPicker/StatsPicker';
 export { Input, InputStatus } from './Input/Input';
 
 // Visualizations

+ 14 - 1
packages/grafana-ui/src/types/panel.ts

@@ -21,12 +21,17 @@ export interface PanelEditorProps<T = any> {
   onOptionsChange: (options: T) => void;
 }
 
+/**
+ * Called when a panel is first loaded with existing options
+ */
+export type PanelMigrationHook<TOptions = any> = (options: Partial<TOptions>) => Partial<TOptions>;
+
 /**
  * Called before a panel is initalized
  */
 export type PanelTypeChangedHook<TOptions = any> = (
   options: Partial<TOptions>,
-  prevPluginId?: string,
+  prevPluginId: string,
   prevOptions?: any
 ) => Partial<TOptions>;
 
@@ -35,6 +40,7 @@ export class ReactPanelPlugin<TOptions = any> {
   editor?: ComponentClass<PanelEditorProps<TOptions>>;
   defaults?: TOptions;
 
+  panelMigrationHook?: PanelMigrationHook<TOptions>;
   panelTypeChangedHook?: PanelTypeChangedHook<TOptions>;
 
   constructor(panel: ComponentClass<PanelProps<TOptions>>) {
@@ -49,6 +55,13 @@ export class ReactPanelPlugin<TOptions = any> {
     this.defaults = defaults;
   }
 
+  /**
+   * Called when the panel first loaded with
+   */
+  setPanelMigrationHook(v: PanelMigrationHook<TOptions>) {
+    this.panelMigrationHook = v;
+  }
+
   /**
    * Called when the visualization changes.
    * Lets you keep whatever settings made sense in the previous panel

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

@@ -5,6 +5,7 @@ export * from './colors';
 export * from './namedColorsPalette';
 export * from './thresholds';
 export * from './string';
+export * from './statsCalculator';
 export * from './displayValue';
 export * from './deprecationWarning';
 export { getMappedValue } from './valueMappings';

+ 92 - 0
packages/grafana-ui/src/utils/statsCalculator.test.ts

@@ -0,0 +1,92 @@
+import { parseCSV } from './processTableData';
+import { getStatsCalculators, StatID, calculateStats } from './statsCalculator';
+
+import _ from 'lodash';
+
+describe('Stats Calculators', () => {
+  const basicTable = parseCSV('a,b,c\n10,20,30\n20,30,40');
+
+  it('should load all standard stats', () => {
+    const names = [
+      StatID.sum,
+      StatID.max,
+      StatID.min,
+      StatID.logmin,
+      StatID.mean,
+      StatID.last,
+      StatID.first,
+      StatID.count,
+      StatID.range,
+      StatID.diff,
+      StatID.step,
+      StatID.delta,
+      // StatID.allIsZero,
+      // StatID.allIsNull,
+    ];
+    const stats = getStatsCalculators(names);
+    expect(stats.length).toBe(names.length);
+  });
+
+  it('should fail to load unknown stats', () => {
+    const names = ['not a stat', StatID.max, StatID.min, 'also not a stat'];
+    const stats = getStatsCalculators(names);
+    expect(stats.length).toBe(2);
+
+    const found = stats.map(v => v.id);
+    const notFound = _.difference(names, found);
+    expect(notFound.length).toBe(2);
+
+    expect(notFound[0]).toBe('not a stat');
+  });
+
+  it('should calculate basic stats', () => {
+    const stats = calculateStats({
+      table: basicTable,
+      columnIndex: 0,
+      stats: ['first', 'last', 'mean'],
+    });
+
+    // First
+    expect(stats.first).toEqual(10);
+
+    // Last
+    expect(stats.last).toEqual(20);
+
+    // Mean
+    expect(stats.mean).toEqual(15);
+  });
+
+  it('should support a single stat also', () => {
+    const stats = calculateStats({
+      table: basicTable,
+      columnIndex: 0,
+      stats: ['first'],
+    });
+
+    // Should do the simple version that just looks up value
+    expect(Object.keys(stats).length).toEqual(1);
+    expect(stats.first).toEqual(10);
+  });
+
+  it('should get non standard stats', () => {
+    const stats = calculateStats({
+      table: basicTable,
+      columnIndex: 0,
+      stats: [StatID.distinctCount, StatID.changeCount],
+    });
+
+    expect(stats.distinctCount).toEqual(2);
+    expect(stats.changeCount).toEqual(1);
+  });
+
+  it('should calculate step', () => {
+    const stats = calculateStats({
+      table: { columns: [{ text: 'A' }], rows: [[100], [200], [300], [400]] },
+      columnIndex: 0,
+      stats: [StatID.step, StatID.delta],
+    });
+
+    expect(stats.step).toEqual(100);
+    expect(stats.delta).toEqual(300);
+  });
+});

+ 405 - 0
packages/grafana-ui/src/utils/statsCalculator.ts

@@ -0,0 +1,405 @@
+// Libraries
+import isNumber from 'lodash/isNumber';
+
+import { TableData, NullValueMode } from '../types/index';
+
+export enum StatID {
+  sum = 'sum',
+  max = 'max',
+  min = 'min',
+  logmin = 'logmin',
+  mean = 'mean',
+  last = 'last',
+  first = 'first',
+  count = 'count',
+  range = 'range',
+  diff = 'diff',
+  delta = 'delta',
+  step = 'step',
+
+  changeCount = 'changeCount',
+  distinctCount = 'distinctCount',
+
+  allIsZero = 'allIsZero',
+  allIsNull = 'allIsNull',
+}
+
+export interface ColumnStats {
+  [key: string]: any;
+}
+
+// Internal function
+type StatCalculator = (table: TableData, columnIndex: number, ignoreNulls: boolean, nullAsZero: boolean) => ColumnStats;
+
+export interface StatCalculatorInfo {
+  id: string;
+  name: string;
+  description: string;
+  alias?: string; // optional secondary key.  'avg' vs 'mean', 'total' vs 'sum'
+
+  // Internal details
+  emptyInputResult?: any; // typically null, but some things like 'count' & 'sum' should be zero
+  standard: boolean; // The most common stats can all be calculated in a single pass
+  calculator?: StatCalculator;
+}
+
+/**
+ * @param ids list of stat names or null to get all of them
+ */
+export function getStatsCalculators(ids?: string[]): StatCalculatorInfo[] {
+  if (ids === null || ids === undefined) {
+    if (!hasBuiltIndex) {
+      getById(StatID.mean);
+    }
+    return listOfStats;
+  }
+
+  return ids.reduce((list, id) => {
+    const stat = getById(id);
+    if (stat) {
+      list.push(stat);
+    }
+    return list;
+  }, new Array<StatCalculatorInfo>());
+}
+
+export interface CalculateStatsOptions {
+  table: TableData;
+  columnIndex: number;
+  stats: string[]; // The stats to calculate
+  nullValueMode?: NullValueMode;
+}
+
+/**
+ * @returns an object with a key for each selected stat
+ */
+export function calculateStats(options: CalculateStatsOptions): ColumnStats {
+  const { table, columnIndex, stats, nullValueMode } = options;
+
+  if (!stats || stats.length < 1) {
+    return {};
+  }
+
+  const queue = getStatsCalculators(stats);
+
+  // Return early for empty tables
+  // This lets the concrete implementations assume at least one row
+  if (!table.rows || table.rows.length < 1) {
+    const stats = {} as ColumnStats;
+    for (const stat of queue) {
+      stats[stat.id] = stat.emptyInputResult !== null ? stat.emptyInputResult : null;
+    }
+    return stats;
+  }
+
+  const ignoreNulls = nullValueMode === NullValueMode.Ignore;
+  const nullAsZero = nullValueMode === NullValueMode.AsZero;
+
+  // Avoid calculating all the standard stats if possible
+  if (queue.length === 1 && queue[0].calculator) {
+    return queue[0].calculator(table, columnIndex, ignoreNulls, nullAsZero);
+  }
+
+  // For now everything can use the standard stats
+  let values = standardStatsStat(table, columnIndex, ignoreNulls, nullAsZero);
+  for (const calc of queue) {
+    if (!values.hasOwnProperty(calc.id) && calc.calculator) {
+      values = {
+        ...values,
+        ...calc.calculator(table, columnIndex, ignoreNulls, nullAsZero),
+      };
+    }
+  }
+  return values;
+}
+
+// ------------------------------------------------------------------------------
+//
+//  No Exported symbols below here.
+//
+// ------------------------------------------------------------------------------
+
+// private registry of all stats
+interface TableStatIndex {
+  [id: string]: StatCalculatorInfo;
+}
+
+const listOfStats: StatCalculatorInfo[] = [];
+const index: TableStatIndex = {};
+let hasBuiltIndex = false;
+
+function getById(id: string): StatCalculatorInfo | undefined {
+  if (!hasBuiltIndex) {
+    [
+      {
+        id: StatID.last,
+        name: 'Last',
+        description: 'Last Value (current)',
+        standard: true,
+        alias: 'current',
+        calculator: calculateLast,
+      },
+      { id: StatID.first, name: 'First', description: 'First Value', standard: true, calculator: calculateFirst },
+      { id: StatID.min, name: 'Min', description: 'Minimum Value', standard: true },
+      { id: StatID.max, name: 'Max', description: 'Maximum Value', standard: true },
+      { id: StatID.mean, name: 'Mean', description: 'Average Value', standard: true, alias: 'avg' },
+      {
+        id: StatID.sum,
+        name: 'Total',
+        description: 'The sum of all values',
+        emptyInputResult: 0,
+        standard: true,
+        alias: 'total',
+      },
+      {
+        id: StatID.count,
+        name: 'Count',
+        description: 'Number of values in response',
+        emptyInputResult: 0,
+        standard: true,
+      },
+      {
+        id: StatID.range,
+        name: 'Range',
+        description: 'Difference between minimum and maximum values',
+        standard: true,
+      },
+      {
+        id: StatID.delta,
+        name: 'Delta',
+        description: 'Cumulative change in value',
+        standard: true,
+      },
+      {
+        id: StatID.step,
+        name: 'Step',
+        description: 'Minimum interval between values',
+        standard: true,
+      },
+      {
+        id: StatID.diff,
+        name: 'Difference',
+        description: 'Difference between first and last values',
+        standard: true,
+      },
+      {
+        id: StatID.logmin,
+        name: 'Min (above zero)',
+        description: 'Used for log min scale',
+        standard: true,
+      },
+      {
+        id: StatID.changeCount,
+        name: 'Change Count',
+        description: 'Number of times the value changes',
+        standard: false,
+        calculator: calculateChangeCount,
+      },
+      {
+        id: StatID.distinctCount,
+        name: 'Distinct Count',
+        description: 'Number of distinct values',
+        standard: false,
+        calculator: calculateDistinctCount,
+      },
+    ].forEach(info => {
+      const { id, alias } = info;
+      if (index.hasOwnProperty(id)) {
+        console.warn('Duplicate Stat', id, info, index);
+      }
+      index[id] = info;
+      if (alias) {
+        if (index.hasOwnProperty(alias)) {
+          console.warn('Duplicate Stat (alias)', alias, info, index);
+        }
+        index[alias] = info;
+      }
+      listOfStats.push(info);
+    });
+    hasBuiltIndex = true;
+  }
+
+  return index[id];
+}
+
+function standardStatsStat(
+  data: TableData,
+  columnIndex: number,
+  ignoreNulls: boolean,
+  nullAsZero: boolean
+): ColumnStats {
+  const stats = {
+    sum: 0,
+    max: -Number.MAX_VALUE,
+    min: Number.MAX_VALUE,
+    logmin: Number.MAX_VALUE,
+    mean: null,
+    last: null,
+    first: null,
+    count: 0,
+    nonNullCount: 0,
+    allIsNull: true,
+    allIsZero: false,
+    range: null,
+    diff: null,
+    delta: 0,
+    step: Number.MAX_VALUE,
+
+    // Just used for calcutations -- not exposed as a stat
+    previousDeltaUp: true,
+  } as ColumnStats;
+
+  for (let i = 0; i < data.rows.length; i++) {
+    let currentValue = data.rows[i][columnIndex];
+
+    if (currentValue === null) {
+      if (ignoreNulls) {
+        continue;
+      }
+      if (nullAsZero) {
+        currentValue = 0;
+      }
+    }
+
+    if (currentValue !== null) {
+      const isFirst = stats.first === null;
+      if (isFirst) {
+        stats.first = currentValue;
+      }
+
+      if (isNumber(currentValue)) {
+        stats.sum += currentValue;
+        stats.allIsNull = false;
+        stats.nonNullCount++;
+
+        if (!isFirst) {
+          const step = currentValue - stats.last!;
+          if (stats.step > step) {
+            stats.step = step; // the minimum interval
+          }
+
+          if (stats.last! > currentValue) {
+            // counter reset
+            stats.previousDeltaUp = false;
+            if (i === data.rows.length - 1) {
+              // reset on last
+              stats.delta += currentValue;
+            }
+          } else {
+            if (stats.previousDeltaUp) {
+              stats.delta += step; // normal increment
+            } else {
+              stats.delta += currentValue; // account for counter reset
+            }
+            stats.previousDeltaUp = true;
+          }
+        }
+
+        if (currentValue > stats.max) {
+          stats.max = currentValue;
+        }
+
+        if (currentValue < stats.min) {
+          stats.min = currentValue;
+        }
+
+        if (currentValue < stats.logmin && currentValue > 0) {
+          stats.logmin = currentValue;
+        }
+      }
+
+      if (currentValue !== 0) {
+        stats.allIsZero = false;
+      }
+
+      stats.last = currentValue;
+    }
+  }
+
+  if (stats.max === -Number.MAX_VALUE) {
+    stats.max = null;
+  }
+
+  if (stats.min === Number.MAX_VALUE) {
+    stats.min = null;
+  }
+
+  if (stats.step === Number.MAX_VALUE) {
+    stats.step = null;
+  }
+
+  if (stats.nonNullCount > 0) {
+    stats.mean = stats.sum! / stats.nonNullCount;
+  }
+
+  if (stats.max !== null && stats.min !== null) {
+    stats.range = stats.max - stats.min;
+  }
+
+  if (stats.first !== null && stats.last !== null) {
+    if (isNumber(stats.first) && isNumber(stats.last)) {
+      stats.diff = stats.last - stats.first;
+    }
+  }
+
+  return stats;
+}
+
+function calculateFirst(data: TableData, columnIndex: number, ignoreNulls: boolean, nullAsZero: boolean): ColumnStats {
+  return { first: data.rows[0][columnIndex] };
+}
+
+function calculateLast(data: TableData, columnIndex: number, ignoreNulls: boolean, nullAsZero: boolean): ColumnStats {
+  return { last: data.rows[data.rows.length - 1][columnIndex] };
+}
+
+function calculateChangeCount(
+  data: TableData,
+  columnIndex: number,
+  ignoreNulls: boolean,
+  nullAsZero: boolean
+): ColumnStats {
+  let count = 0;
+  let first = true;
+  let last: any = null;
+  for (let i = 0; i < data.rows.length; i++) {
+    let currentValue = data.rows[i][columnIndex];
+    if (currentValue === null) {
+      if (ignoreNulls) {
+        continue;
+      }
+      if (nullAsZero) {
+        currentValue = 0;
+      }
+    }
+    if (!first && last !== currentValue) {
+      count++;
+    }
+    first = false;
+    last = currentValue;
+  }
+
+  return { changeCount: count };
+}
+
+function calculateDistinctCount(
+  data: TableData,
+  columnIndex: number,
+  ignoreNulls: boolean,
+  nullAsZero: boolean
+): ColumnStats {
+  const distinct = new Set<any>();
+  for (let i = 0; i < data.rows.length; i++) {
+    let currentValue = data.rows[i][columnIndex];
+    if (currentValue === null) {
+      if (ignoreNulls) {
+        continue;
+      }
+      if (nullAsZero) {
+        currentValue = 0;
+      }
+    }
+    distinct.add(currentValue);
+  }
+  return { distinctCount: distinct.size };
+}

+ 5 - 0
public/app/features/dashboard/dashgrid/DashboardPanel.tsx

@@ -98,6 +98,11 @@ export class DashboardPanel extends PureComponent<Props, State> {
           }
           panel.changeType(pluginId, hook);
         }
+      } else if (plugin.exports && plugin.exports.reactPanel && panel.options) {
+        const hook = plugin.exports.reactPanel.panelMigrationHook;
+        if (hook) {
+          panel.options = hook(panel.options);
+        }
       }
 
       this.setState({ plugin, angularPanel: null });

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

@@ -1,4 +1,4 @@
-import { VizOrientation, SelectOptionItem } from '@grafana/ui';
+import { VizOrientation, SelectOptionItem, StatID } from '@grafana/ui';
 import { SingleStatBaseOptions } from '../singlestat2/types';
 
 export interface BarGaugeOptions extends SingleStatBaseOptions {
@@ -25,7 +25,7 @@ export const defaults: BarGaugeOptions = {
   orientation: VizOrientation.Horizontal,
   valueOptions: {
     unit: 'none',
-    stat: 'avg',
+    stat: StatID.mean,
     prefix: '',
     suffix: '',
     decimals: null,

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

@@ -1,5 +1,5 @@
 import { SingleStatBaseOptions } from '../singlestat2/types';
-import { VizOrientation } from '@grafana/ui';
+import { VizOrientation, StatID } from '@grafana/ui';
 
 export interface GaugeOptions extends SingleStatBaseOptions {
   maxValue: number;
@@ -17,7 +17,7 @@ export const defaults: GaugeOptions = {
     prefix: '',
     suffix: '',
     decimals: null,
-    stat: 'avg',
+    stat: StatID.mean,
     unit: 'none',
   },
   valueMappings: [],

+ 19 - 11
public/app/plugins/panel/singlestat2/SingleStatPanel.tsx

@@ -4,7 +4,7 @@ import React, { PureComponent, CSSProperties } from 'react';
 // Types
 import { SingleStatOptions, SingleStatBaseOptions } from './types';
 
-import { DisplayValue, PanelProps, processTimeSeries, NullValueMode, ColumnType } from '@grafana/ui';
+import { DisplayValue, PanelProps, NullValueMode, ColumnType, calculateStats } from '@grafana/ui';
 import { config } from 'app/core/config';
 import { getDisplayProcessor } from '@grafana/ui';
 import { ProcessedValuesRepeater } from './ProcessedValuesRepeater';
@@ -14,7 +14,7 @@ export const getSingleStatValues = (props: PanelProps<SingleStatBaseOptions>): D
   const { valueOptions, valueMappings } = options;
   const { unit, decimals, stat } = valueOptions;
 
-  const processor = getDisplayProcessor({
+  const display = getDisplayProcessor({
     unit,
     decimals,
     mappings: valueMappings,
@@ -25,28 +25,36 @@ export const getSingleStatValues = (props: PanelProps<SingleStatBaseOptions>): D
   });
 
   const values: DisplayValue[] = [];
+
   for (const table of data) {
+    if (stat === 'name') {
+      values.push(display(table.name));
+    }
+
     for (let i = 0; i < table.columns.length; i++) {
       const column = table.columns[i];
 
       // Show all columns that are not 'time'
       if (column.type === ColumnType.number) {
-        const series = processTimeSeries({
-          data: [table],
-          xColumn: i,
-          yColumn: i,
+        const stats = calculateStats({
+          table,
+          columnIndex: i,
+          stats: [stat], // The stats to calculate
           nullValueMode: NullValueMode.Null,
-        })[0];
-
-        const value = stat !== 'name' ? series.stats[stat] : series.label;
-        values.push(processor(value));
+        });
+        const displayValue = display(stats[stat]);
+        values.push(displayValue);
       }
     }
   }
 
   if (values.length === 0) {
-    throw { message: 'Could not find numeric data' };
+    values.push({
+      numeric: 0,
+      text: 'No data',
+    });
   }
+
   return values;
 };
 

+ 12 - 20
public/app/plugins/panel/singlestat2/SingleStatValueEditor.tsx

@@ -2,25 +2,11 @@
 import React, { PureComponent } from 'react';
 
 // Components
-import { FormField, FormLabel, PanelOptionsGroup, Select, UnitPicker } from '@grafana/ui';
+import { FormField, FormLabel, PanelOptionsGroup, StatsPicker, UnitPicker, StatID } from '@grafana/ui';
 
 // Types
 import { SingleStatValueOptions } from './types';
 
-const statOptions = [
-  { value: 'min', label: 'Min' },
-  { value: 'max', label: 'Max' },
-  { value: 'avg', label: 'Average' },
-  { value: 'current', label: 'Current' },
-  { value: 'total', label: 'Total' },
-  { value: 'name', label: 'Name' },
-  { value: 'first', label: 'First' },
-  { value: 'delta', label: 'Delta' },
-  { value: 'diff', label: 'Difference' },
-  { value: 'range', label: 'Range' },
-  { value: 'last_time', label: 'Time of last point' },
-];
-
 const labelWidth = 6;
 
 export interface Props {
@@ -30,7 +16,11 @@ export interface Props {
 
 export class SingleStatValueEditor extends PureComponent<Props> {
   onUnitChange = unit => this.props.onChange({ ...this.props.options, unit: unit.value });
-  onStatChange = stat => this.props.onChange({ ...this.props.options, stat: stat.value });
+
+  onStatsChange = stats => {
+    const stat = stats[0] || StatID.mean;
+    this.props.onChange({ ...this.props.options, stat });
+  };
 
   onDecimalChange = event => {
     if (!isNaN(event.target.value)) {
@@ -61,11 +51,13 @@ export class SingleStatValueEditor extends PureComponent<Props> {
       <PanelOptionsGroup title="Value">
         <div className="gf-form">
           <FormLabel width={labelWidth}>Stat</FormLabel>
-          <Select
+          <StatsPicker
             width={12}
-            options={statOptions}
-            onChange={this.onStatChange}
-            value={statOptions.find(option => option.value === stat)}
+            placeholder="Choose Stat"
+            defaultStat={StatID.mean}
+            allowMultiple={false}
+            stats={[stat]}
+            onChange={this.onStatsChange}
           />
         </div>
         <div className="gf-form">

+ 12 - 2
public/app/plugins/panel/singlestat2/module.tsx

@@ -1,4 +1,4 @@
-import { ReactPanelPlugin } from '@grafana/ui';
+import { ReactPanelPlugin, getStatsCalculators } from '@grafana/ui';
 import { SingleStatOptions, defaults, SingleStatBaseOptions } from './types';
 import { SingleStatPanel } from './SingleStatPanel';
 import cloneDeep from 'lodash/cloneDeep';
@@ -10,7 +10,7 @@ const optionsToKeep = ['valueOptions', 'stat', 'maxValue', 'maxValue', 'threshol
 
 export const singleStatBaseOptionsCheck = (
   options: Partial<SingleStatBaseOptions>,
-  prevPluginId?: string,
+  prevPluginId: string,
   prevOptions?: any
 ) => {
   if (prevOptions) {
@@ -20,10 +20,20 @@ export const singleStatBaseOptionsCheck = (
       }
     });
   }
+  return options;
+};
 
+export const singleStatMigrationCheck = (options: Partial<SingleStatBaseOptions>) => {
+  // 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;
 };
 
 reactPanel.setEditor(SingleStatEditor);
 reactPanel.setDefaults(defaults);
 reactPanel.setPanelTypeChangedHook(singleStatBaseOptionsCheck);
+reactPanel.setPanelMigrationHook(singleStatMigrationCheck);

+ 2 - 2
public/app/plugins/panel/singlestat2/types.ts

@@ -1,4 +1,4 @@
-import { VizOrientation, ValueMapping, Threshold } from '@grafana/ui';
+import { VizOrientation, ValueMapping, Threshold, StatID } from '@grafana/ui';
 
 export interface SingleStatBaseOptions {
   valueMappings: ValueMapping[];
@@ -24,7 +24,7 @@ export const defaults: SingleStatOptions = {
     prefix: '',
     suffix: '',
     decimals: null,
-    stat: 'avg',
+    stat: StatID.mean,
     unit: 'none',
   },
   valueMappings: [],