Browse Source

Transformers: configure result transformations after query(alpha) (#18740)

Ryan McKinley 6 years ago
parent
commit
7d32caeac2
35 changed files with 811 additions and 96 deletions
  1. 4 0
      conf/defaults.ini
  2. 4 3
      packages/grafana-data/src/utils/transformers/filter.ts
  3. 66 0
      packages/grafana-data/src/utils/transformers/filterByName.test.ts
  4. 38 0
      packages/grafana-data/src/utils/transformers/filterByName.ts
  5. 2 0
      packages/grafana-data/src/utils/transformers/ids.ts
  6. 23 0
      packages/grafana-data/src/utils/transformers/noop.ts
  7. 6 6
      packages/grafana-data/src/utils/transformers/reduce.ts
  8. 8 5
      packages/grafana-data/src/utils/transformers/transformers.ts
  9. 6 0
      packages/grafana-runtime/src/config.ts
  10. 44 0
      packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.tsx
  11. 5 2
      packages/grafana-ui/src/components/JSONFormatter/JSONFormatter.tsx
  12. 0 0
      packages/grafana-ui/src/components/JSONFormatter/json_explorer/helpers.ts
  13. 5 5
      packages/grafana-ui/src/components/JSONFormatter/json_explorer/json_explorer.ts
  14. 1 1
      packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx
  15. 163 0
      packages/grafana-ui/src/components/TransformersUI/FilterByNameTransformerEditor.tsx
  16. 35 0
      packages/grafana-ui/src/components/TransformersUI/ReduceTransformerEditor.tsx
  17. 85 0
      packages/grafana-ui/src/components/TransformersUI/TransformationRow.tsx
  18. 127 0
      packages/grafana-ui/src/components/TransformersUI/TransformationsEditor.tsx
  19. 8 0
      packages/grafana-ui/src/components/TransformersUI/transformers.ts
  20. 15 0
      packages/grafana-ui/src/components/TransformersUI/types.ts
  21. 6 1
      packages/grafana-ui/src/components/index.ts
  22. 1 0
      pkg/api/frontendsettings.go
  23. 13 0
      pkg/setting/setting.go
  24. 1 1
      public/app/core/components/jsontree/jsontree.ts
  25. 1 2
      public/app/core/core.ts
  26. 1 2
      public/app/features/alerting/TestRuleResult.tsx
  27. 1 1
      public/app/features/dashboard/dashgrid/PanelChrome.tsx
  28. 66 28
      public/app/features/dashboard/panel_editor/QueriesTab.tsx
  29. 1 2
      public/app/features/dashboard/panel_editor/QueryInspector.tsx
  30. 11 2
      public/app/features/dashboard/state/PanelModel.ts
  31. 51 8
      public/app/features/dashboard/state/PanelQueryRunner.ts
  32. 1 0
      public/app/features/panel/metrics_panel_ctrl.ts
  33. 10 26
      public/app/features/plugins/PluginStateInfo.tsx
  34. 1 0
      public/app/plugins/datasource/dashboard/SharedQueryRunner.ts
  35. 1 1
      public/app/plugins/panel/graph/module.ts

+ 4 - 0
conf/defaults.ini

@@ -682,3 +682,7 @@ app_tls_skip_verify_insecure = false
 
 [enterprise]
 license_path =
+
+[feature_toggles]
+# enable features, separated by spaces
+enable =

+ 4 - 3
packages/grafana-data/src/utils/transformers/filter.ts

@@ -1,4 +1,5 @@
-import { DataTransformerInfo, NoopDataTransformer } from './transformers';
+import { DataTransformerInfo } from './transformers';
+import { noopTransformer } from './noop';
 import { DataFrame, Field } from '../../types/dataFrame';
 import { FieldMatcherID } from '../matchers/ids';
 import { DataTransformerID } from './ids';
@@ -23,7 +24,7 @@ export const filterFieldsTransformer: DataTransformerInfo<FilterOptions> = {
    */
   transformer: (options: FilterOptions) => {
     if (!options.include && !options.exclude) {
-      return NoopDataTransformer;
+      return noopTransformer.transformer({});
     }
 
     const include = options.include ? getFieldMatcher(options.include) : null;
@@ -75,7 +76,7 @@ export const filterFramesTransformer: DataTransformerInfo<FilterOptions> = {
    */
   transformer: (options: FilterOptions) => {
     if (!options.include && !options.exclude) {
-      return NoopDataTransformer;
+      return noopTransformer.transformer({});
     }
 
     const include = options.include ? getFrameMatchers(options.include) : null;

+ 66 - 0
packages/grafana-data/src/utils/transformers/filterByName.test.ts

@@ -0,0 +1,66 @@
+import { toDataFrame, transformDataFrame } from '../index';
+import { FieldType } from '../../index';
+import { DataTransformerID } from './ids';
+
+export const seriesWithNamesToMatch = toDataFrame({
+  fields: [
+    { name: 'startsWithA', type: FieldType.time, values: [1000, 2000] },
+    { name: 'B', type: FieldType.boolean, values: [true, false] },
+    { name: 'startsWithC', type: FieldType.string, values: ['a', 'b'] },
+    { name: 'D', type: FieldType.number, values: [1, 2] },
+  ],
+});
+
+describe('filterByName transformer', () => {
+  it('returns original series if no options provided', () => {
+    const cfg = {
+      id: DataTransformerID.filterFields,
+      options: {},
+    };
+
+    const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0];
+    expect(filtered.fields.length).toBe(4);
+  });
+
+  describe('respects', () => {
+    it('inclusion', () => {
+      const cfg = {
+        id: DataTransformerID.filterFieldsByName,
+        options: {
+          include: '/^(startsWith)/',
+        },
+      };
+
+      const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0];
+      expect(filtered.fields.length).toBe(2);
+      expect(filtered.fields[0].name).toBe('startsWithA');
+    });
+
+    it('exclusion', () => {
+      const cfg = {
+        id: DataTransformerID.filterFieldsByName,
+        options: {
+          exclude: '/^(startsWith)/',
+        },
+      };
+
+      const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0];
+      expect(filtered.fields.length).toBe(2);
+      expect(filtered.fields[0].name).toBe('B');
+    });
+
+    it('inclusion and exclusion', () => {
+      const cfg = {
+        id: DataTransformerID.filterFieldsByName,
+        options: {
+          exclude: '/^(startsWith)/',
+          include: `/^(B)$/`,
+        },
+      };
+
+      const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0];
+      expect(filtered.fields.length).toBe(1);
+      expect(filtered.fields[0].name).toBe('B');
+    });
+  });
+});

+ 38 - 0
packages/grafana-data/src/utils/transformers/filterByName.ts

@@ -0,0 +1,38 @@
+import { DataTransformerInfo } from './transformers';
+import { FieldMatcherID } from '../matchers/ids';
+import { DataTransformerID } from './ids';
+import { filterFieldsTransformer, FilterOptions } from './filter';
+
+export interface FilterFieldsByNameTransformerOptions {
+  include?: string;
+  exclude?: string;
+}
+
+export const filterFieldsByNameTransformer: DataTransformerInfo<FilterFieldsByNameTransformerOptions> = {
+  id: DataTransformerID.filterFieldsByName,
+  name: 'Filter fields by name',
+  description: 'select a subset of fields',
+  defaultOptions: {},
+
+  /**
+   * Return a modified copy of the series.  If the transform is not or should not
+   * be applied, just return the input series
+   */
+  transformer: (options: FilterFieldsByNameTransformerOptions) => {
+    const filterOptions: FilterOptions = {};
+    if (options.include) {
+      filterOptions.include = {
+        id: FieldMatcherID.byName,
+        options: options.include,
+      };
+    }
+    if (options.exclude) {
+      filterOptions.exclude = {
+        id: FieldMatcherID.byName,
+        options: options.exclude,
+      };
+    }
+
+    return filterFieldsTransformer.transformer(filterOptions);
+  },
+};

+ 2 - 0
packages/grafana-data/src/utils/transformers/ids.ts

@@ -5,5 +5,7 @@ export enum DataTransformerID {
   reduce = 'reduce', // Run calculations on fields
 
   filterFields = 'filterFields', // Pick some fields (keep all frames)
+  filterFieldsByName = 'filterFieldsByName', // Pick fields with name matching regex (keep all frames)
   filterFrames = 'filterFrames', // Pick some frames (keep all fields)
+  noop = 'noop', // Does nothing to the dataframe
 }

+ 23 - 0
packages/grafana-data/src/utils/transformers/noop.ts

@@ -0,0 +1,23 @@
+import { DataTransformerInfo } from './transformers';
+import { DataTransformerID } from './ids';
+import { DataFrame } from '../../types/dataFrame';
+
+export interface NoopTransformerOptions {
+  include?: string;
+  exclude?: string;
+}
+
+export const noopTransformer: DataTransformerInfo<NoopTransformerOptions> = {
+  id: DataTransformerID.noop,
+  name: 'noop',
+  description: 'No-operation transformer',
+  defaultOptions: {},
+
+  /**
+   * Return a modified copy of the series.  If the transform is not or should not
+   * be applied, just return the input series
+   */
+  transformer: (options: NoopTransformerOptions) => {
+    return (data: DataFrame[]) => data;
+  },
+};

+ 6 - 6
packages/grafana-data/src/utils/transformers/reduce.ts

@@ -8,26 +8,26 @@ import { KeyValue } from '../../types/data';
 import { ArrayVector } from '../vector';
 import { guessFieldTypeForField } from '../processDataFrame';
 
-export interface ReduceOptions {
-  reducers: string[];
+export interface ReduceTransformerOptions {
+  reducers: ReducerID[];
   fields?: MatcherConfig; // Assume all fields
 }
 
-export const reduceTransformer: DataTransformerInfo<ReduceOptions> = {
+export const reduceTransformer: DataTransformerInfo<ReduceTransformerOptions> = {
   id: DataTransformerID.reduce,
   name: 'Reducer',
   description: 'Return a DataFrame with the reduction results',
   defaultOptions: {
-    calcs: [ReducerID.min, ReducerID.max, ReducerID.mean, ReducerID.last],
+    reducers: [ReducerID.min, ReducerID.max, ReducerID.mean, ReducerID.last],
   },
 
   /**
    * Return a modified copy of the series.  If the transform is not or should not
    * be applied, just return the input series
    */
-  transformer: (options: ReduceOptions) => {
+  transformer: (options: ReduceTransformerOptions) => {
     const matcher = options.fields ? getFieldMatcher(options.fields) : alwaysFieldMatcher;
-    const calculators = fieldReducers.list(options.reducers);
+    const calculators = options.reducers && options.reducers.length ? fieldReducers.list(options.reducers) : [];
     const reducers = calculators.map(c => c.id);
 
     return (data: DataFrame[]) => {

+ 8 - 5
packages/grafana-data/src/utils/transformers/transformers.ts

@@ -15,9 +15,6 @@ export interface DataTransformerConfig<TOptions = any> {
   options: TOptions;
 }
 
-// Transformer that does nothing
-export const NoopDataTransformer = (data: DataFrame[]) => data;
-
 /**
  * Apply configured transformations to the input data
  */
@@ -49,8 +46,10 @@ export function transformDataFrame(options: DataTransformerConfig[], data: DataF
 // Initalize the Registry
 
 import { appendTransformer, AppendOptions } from './append';
-import { reduceTransformer, ReduceOptions } from './reduce';
+import { reduceTransformer, ReduceTransformerOptions } from './reduce';
 import { filterFieldsTransformer, filterFramesTransformer } from './filter';
+import { filterFieldsByNameTransformer, FilterFieldsByNameTransformerOptions } from './filterByName';
+import { noopTransformer } from './noop';
 
 /**
  * Registry of transformation options that can be driven by
@@ -69,14 +68,18 @@ class TransformerRegistry extends Registry<DataTransformerInfo> {
     return appendTransformer.transformer(options || appendTransformer.defaultOptions)(data)[0];
   }
 
-  reduce(data: DataFrame[], options: ReduceOptions): DataFrame[] {
+  reduce(data: DataFrame[], options: ReduceTransformerOptions): DataFrame[] {
     return reduceTransformer.transformer(options)(data);
   }
 }
 
 export const dataTransformers = new TransformerRegistry(() => [
+  noopTransformer,
   filterFieldsTransformer,
+  filterFieldsByNameTransformer,
   filterFramesTransformer,
   appendTransformer,
   reduceTransformer,
 ]);
+
+export { ReduceTransformerOptions, FilterFieldsByNameTransformerOptions };

+ 6 - 0
packages/grafana-runtime/src/config.ts

@@ -10,6 +10,9 @@ export interface BuildInfo {
   hasUpdate: boolean;
 }
 
+interface FeatureToggles {
+  transformations: boolean;
+}
 export class GrafanaBootConfig {
   datasources: { [str: string]: DataSourceInstanceSettings } = {};
   panels: { [key: string]: PanelPluginMeta } = {};
@@ -41,6 +44,9 @@ export class GrafanaBootConfig {
   disableSanitizeHtml = false;
   theme: GrafanaTheme;
   pluginsToPreload: string[] = [];
+  featureToggles: FeatureToggles = {
+    transformations: false,
+  };
 
   constructor(options: GrafanaBootConfig) {
     this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark);

+ 44 - 0
packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.tsx

@@ -0,0 +1,44 @@
+import React, { FC, useContext } from 'react';
+import { css, cx } from 'emotion';
+import { PluginState, ThemeContext } from '../../index';
+import { Tooltip } from '../index';
+
+interface Props {
+  state?: PluginState;
+  text?: JSX.Element;
+  className?: string;
+}
+
+export const AlphaNotice: FC<Props> = ({ state, text, className }) => {
+  const tooltipContent = text || (
+    <div>
+      <h5>Alpha Feature</h5>
+      <p>This feature is a work in progress and updates may include breaking changes.</p>
+    </div>
+  );
+
+  const theme = useContext(ThemeContext);
+
+  const styles = cx(
+    className,
+    css`
+      background: linear-gradient(to bottom, ${theme.colors.blueBase}, ${theme.colors.blueShade});
+      color: ${theme.colors.gray7};
+      white-space: nowrap;
+      border-radius: 3px;
+      text-shadow: none;
+      font-size: 13px;
+      padding: 4px 8px;
+      cursor: help;
+      display: inline-block;
+    `
+  );
+
+  return (
+    <Tooltip content={tooltipContent} theme={'info'} placement={'top'}>
+      <div className={styles}>
+        <i className="fa fa-warning" /> {state}
+      </div>
+    </Tooltip>
+  );
+};

+ 5 - 2
public/app/core/components/JSONFormatter/JSONFormatter.tsx → packages/grafana-ui/src/components/JSONFormatter/JSONFormatter.tsx

@@ -1,5 +1,5 @@
-import React, { PureComponent, createRef } from 'react';
-import { JsonExplorer } from 'app/core/core'; // We have made some monkey-patching of json-formatter-js so we can't switch right now
+import React, { PureComponent, createRef } from 'react';
+import { JsonExplorer } from './json_explorer/json_explorer'; // We have made some monkey-patching of json-formatter-js so we can't switch right now
 
 interface Props {
   className?: string;
@@ -31,10 +31,13 @@ export class JSONFormatter extends PureComponent<Props> {
     const { json, config, open, onDidRender } = this.props;
     const wrapperEl = this.wrapperRef.current;
     const formatter = new JsonExplorer(json, open, config);
+    // @ts-ignore
     const hasChildren: boolean = wrapperEl.hasChildNodes();
     if (hasChildren) {
+      // @ts-ignore
       wrapperEl.replaceChild(formatter.render(), wrapperEl.lastChild);
     } else {
+      // @ts-ignore
       wrapperEl.appendChild(formatter.render());
     }
 

+ 0 - 0
public/app/core/components/json_explorer/helpers.ts → packages/grafana-ui/src/components/JSONFormatter/json_explorer/helpers.ts


+ 5 - 5
public/app/core/components/json_explorer/json_explorer.ts → packages/grafana-ui/src/components/JSONFormatter/json_explorer/json_explorer.ts

@@ -28,7 +28,6 @@ export interface JsonExplorerConfig {
 const _defaultConfig: JsonExplorerConfig = {
   animateOpen: true,
   animateClose: true,
-  theme: null,
 };
 
 /**
@@ -39,10 +38,10 @@ const _defaultConfig: JsonExplorerConfig = {
  */
 export class JsonExplorer {
   // Hold the open state after the toggler is used
-  private _isOpen: boolean = null;
+  private _isOpen: boolean | null = null;
 
   // A reference to the element that we render to
-  private element: Element;
+  private element: Element | null = null;
 
   private skipChildren = false;
 
@@ -366,7 +365,7 @@ export class JsonExplorer {
    * Animated option is used when user triggers this via a click
    */
   appendChildren(animated = false) {
-    const children = this.element.querySelector(`div.${cssClass('children')}`);
+    const children = this.element && this.element.querySelector(`div.${cssClass('children')}`);
 
     if (!children || this.isEmpty) {
       return;
@@ -404,7 +403,8 @@ export class JsonExplorer {
    * Animated option is used when user triggers this via a click
    */
   removeChildren(animated = false) {
-    const childrenElement = this.element.querySelector(`div.${cssClass('children')}`) as HTMLDivElement;
+    const childrenElement =
+      this.element && (this.element.querySelector(`div.${cssClass('children')}`) as HTMLDivElement);
 
     if (animated) {
       let childrenRemoved = 0;

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

@@ -2,7 +2,7 @@
 import React, { FunctionComponent } from 'react';
 
 interface Props {
-  title?: string;
+  title?: string | JSX.Element;
   onClose?: () => void;
   children: JSX.Element | JSX.Element[] | boolean;
   onAdd?: () => void;

+ 163 - 0
packages/grafana-ui/src/components/TransformersUI/FilterByNameTransformerEditor.tsx

@@ -0,0 +1,163 @@
+import React, { useContext } from 'react';
+import { FilterFieldsByNameTransformerOptions, DataTransformerID, dataTransformers, KeyValue } from '@grafana/data';
+import { TransformerUIProps, TransformerUIRegistyItem } from './types';
+import { ThemeContext } from '../../themes/ThemeContext';
+import { css, cx } from 'emotion';
+import { InlineList } from '../List/InlineList';
+
+interface FilterByNameTransformerEditorProps extends TransformerUIProps<FilterFieldsByNameTransformerOptions> {}
+
+interface FilterByNameTransformerEditorState {
+  include: string;
+  options: FieldNameInfo[];
+  selected: string[];
+}
+
+interface FieldNameInfo {
+  name: string;
+  count: number;
+}
+export class FilterByNameTransformerEditor extends React.PureComponent<
+  FilterByNameTransformerEditorProps,
+  FilterByNameTransformerEditorState
+> {
+  constructor(props: FilterByNameTransformerEditorProps) {
+    super(props);
+    this.state = {
+      include: props.options.include || '',
+      options: [],
+      selected: [],
+    };
+  }
+
+  componentDidMount() {
+    this.initOptions();
+  }
+
+  private initOptions() {
+    const { input, options } = this.props;
+    const configuredOptions = options.include ? options.include.split('|') : [];
+
+    const allNames: FieldNameInfo[] = [];
+    const byName: KeyValue<FieldNameInfo> = {};
+    for (const frame of input) {
+      for (const field of frame.fields) {
+        let v = byName[field.name];
+        if (!v) {
+          v = byName[field.name] = {
+            name: field.name,
+            count: 0,
+          };
+          allNames.push(v);
+        }
+        v.count++;
+      }
+    }
+
+    if (configuredOptions.length) {
+      const options: FieldNameInfo[] = [];
+      const selected: FieldNameInfo[] = [];
+      for (const v of allNames) {
+        if (configuredOptions.includes(v.name)) {
+          selected.push(v);
+        }
+        options.push(v);
+      }
+
+      this.setState({
+        options,
+        selected: selected.map(s => s.name),
+      });
+    } else {
+      this.setState({ options: allNames, selected: [] });
+    }
+  }
+
+  onFieldToggle = (fieldName: string) => {
+    const { selected } = this.state;
+    if (selected.indexOf(fieldName) > -1) {
+      this.onChange(selected.filter(s => s !== fieldName));
+    } else {
+      this.onChange([...selected, fieldName]);
+    }
+  };
+
+  onChange = (selected: string[]) => {
+    this.setState({ selected });
+    this.props.onChange({
+      ...this.props.options,
+      include: selected.join('|'),
+    });
+  };
+
+  render() {
+    const { options, selected } = this.state;
+    return (
+      <>
+        <InlineList
+          items={options}
+          renderItem={(o, i) => {
+            const label = `${o.name}${o.count > 1 ? ' (' + o.count + ')' : ''}`;
+            return (
+              <span
+                className={css`
+                  margin-right: ${i === options.length - 1 ? '0' : '10px'};
+                `}
+              >
+                <FilterPill
+                  onClick={() => {
+                    this.onFieldToggle(o.name);
+                  }}
+                  label={label}
+                  selected={selected.indexOf(o.name) > -1}
+                />
+              </span>
+            );
+          }}
+        />
+      </>
+    );
+  }
+}
+
+interface FilterPillProps {
+  selected: boolean;
+  label: string;
+  onClick: React.MouseEventHandler<HTMLElement>;
+}
+const FilterPill: React.FC<FilterPillProps> = ({ label, selected, onClick }) => {
+  const theme = useContext(ThemeContext);
+  return (
+    <div
+      className={css`
+        padding: ${theme.spacing.xxs} ${theme.spacing.sm};
+        color: white;
+        background: ${selected ? theme.colors.blueLight : theme.colors.blueShade};
+        border-radius: 16px;
+        display: inline-block;
+        cursor: pointer;
+      `}
+      onClick={onClick}
+    >
+      {selected && (
+        <i
+          className={cx(
+            'fa fa-check',
+            css`
+              margin-right: 4px;
+            `
+          )}
+        />
+      )}
+      {label}
+    </div>
+  );
+};
+
+export const filterFieldsByNameTransformRegistryItem: TransformerUIRegistyItem<FilterFieldsByNameTransformerOptions> = {
+  id: DataTransformerID.filterFieldsByName,
+  component: FilterByNameTransformerEditor,
+  transformer: dataTransformers.get(DataTransformerID.filterFieldsByName),
+  name: 'Filter by name',
+  description: 'UI for filter by name transformation',
+};

+ 35 - 0
packages/grafana-ui/src/components/TransformersUI/ReduceTransformerEditor.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+import { StatsPicker } from '../StatsPicker/StatsPicker';
+import { ReduceTransformerOptions, DataTransformerID, ReducerID } from '@grafana/data';
+import { TransformerUIRegistyItem, TransformerUIProps } from './types';
+import { dataTransformers } from '@grafana/data';
+
+// TODO:  Minimal implementation, needs some <3
+export const ReduceTransformerEditor: React.FC<TransformerUIProps<ReduceTransformerOptions>> = ({
+  options,
+  onChange,
+  input,
+}) => {
+  return (
+    <StatsPicker
+      width={12}
+      placeholder="Choose Stat"
+      allowMultiple
+      stats={options.reducers || []}
+      onChange={stats => {
+        onChange({
+          ...options,
+          reducers: stats as ReducerID[],
+        });
+      }}
+    />
+  );
+};
+
+export const reduceTransformRegistryItem: TransformerUIRegistyItem<ReduceTransformerOptions> = {
+  id: DataTransformerID.reduce,
+  component: ReduceTransformerEditor,
+  transformer: dataTransformers.get(DataTransformerID.reduce),
+  name: 'Reduce',
+  description: 'UI for reduce transformation',
+};

+ 85 - 0
packages/grafana-ui/src/components/TransformersUI/TransformationRow.tsx

@@ -0,0 +1,85 @@
+import React, { useContext, useState } from 'react';
+import { ThemeContext } from '../../themes/ThemeContext';
+import { css } from 'emotion';
+import { DataFrame } from '@grafana/data';
+import { JSONFormatter } from '../JSONFormatter/JSONFormatter';
+import { GrafanaTheme } from '../../types/theme';
+
+interface TransformationRowProps {
+  name: string;
+  description: string;
+  editor?: JSX.Element;
+  onRemove: () => void;
+  input: DataFrame[];
+}
+
+const getStyles = (theme: GrafanaTheme) => ({
+  title: css`
+    display: flex;
+    padding: 4px 8px 4px 8px;
+    position: relative;
+    height: 35px;
+    background: ${theme.colors.textFaint};
+    border-radius: 4px 4px 0 0;
+    flex-wrap: nowrap;
+    justify-content: space-between;
+    align-items: center;
+  `,
+  name: css`
+    font-weight: ${theme.typography.weight.semibold};
+    color: ${theme.colors.blue};
+  `,
+  iconRow: css`
+    display: flex;
+  `,
+  icon: css`
+    background: transparent;
+    border: none;
+    box-shadow: none;
+    cursor: pointer;
+    color: ${theme.colors.textWeak};
+    margin-left: ${theme.spacing.sm};
+    &:hover {
+      color: ${theme.colors.text};
+    }
+  `,
+  editor: css`
+    border: 2px dashed ${theme.colors.textFaint};
+    border-top: none;
+    border-radius: 0 0 4px 4px;
+    padding: 8px;
+  `,
+});
+
+export const TransformationRow = ({ onRemove, editor, name, input }: TransformationRowProps) => {
+  const theme = useContext(ThemeContext);
+  const [viewDebug, setViewDebug] = useState(false);
+  const styles = getStyles(theme);
+  return (
+    <div
+      className={css`
+        margin-bottom: 10px;
+      `}
+    >
+      <div className={styles.title}>
+        <div className={styles.name}>{name}</div>
+        <div className={styles.iconRow}>
+          <div onClick={() => setViewDebug(!viewDebug)} className={styles.icon}>
+            <i className="fa fa-fw fa-bug" />
+          </div>
+          <div onClick={onRemove} className={styles.icon}>
+            <i className="fa fa-fw fa-trash" />
+          </div>
+        </div>
+      </div>
+      <div className={styles.editor}>
+        {editor}
+        {viewDebug && (
+          <div>
+            <JSONFormatter json={input} />
+          </div>
+        )}
+      </div>
+    </div>
+  );
+};

+ 127 - 0
packages/grafana-ui/src/components/TransformersUI/TransformationsEditor.tsx

@@ -0,0 +1,127 @@
+import { DataTransformerID, DataTransformerConfig, DataFrame, transformDataFrame } from '@grafana/data';
+import { Select } from '../Select/Select';
+import { transformersUIRegistry } from './transformers';
+import React from 'react';
+import { TransformationRow } from './TransformationRow';
+import { Button } from '../Button/Button';
+import { css } from 'emotion';
+
+interface TransformationsEditorState {
+  updateCounter: number;
+}
+
+interface TransformationsEditorProps {
+  onChange: (transformations: DataTransformerConfig[]) => void;
+  transformations: DataTransformerConfig[];
+  getCurrentData: (applyTransformations?: boolean) => DataFrame[];
+}
+
+export class TransformationsEditor extends React.PureComponent<TransformationsEditorProps, TransformationsEditorState> {
+  state = { updateCounter: 0 };
+
+  onTransformationAdd = () => {
+    const { transformations, onChange } = this.props;
+    onChange([
+      ...transformations,
+      {
+        id: DataTransformerID.noop,
+        options: {},
+      },
+    ]);
+    this.setState({ updateCounter: this.state.updateCounter + 1 });
+  };
+
+  onTransformationChange = (idx: number, config: DataTransformerConfig) => {
+    const { transformations, onChange } = this.props;
+    transformations[idx] = config;
+    onChange(transformations);
+    this.setState({ updateCounter: this.state.updateCounter + 1 });
+  };
+
+  onTransformationRemove = (idx: number) => {
+    const { transformations, onChange } = this.props;
+    transformations.splice(idx, 1);
+    onChange(transformations);
+    this.setState({ updateCounter: this.state.updateCounter + 1 });
+  };
+
+  renderTransformationEditors = () => {
+    const { transformations, getCurrentData } = this.props;
+    const hasTransformations = transformations.length > 0;
+    const preTransformData = getCurrentData(false);
+
+    if (!hasTransformations) {
+      return undefined;
+    }
+
+    const availableTransformers = transformersUIRegistry.list().map(t => {
+      return {
+        value: t.transformer.id,
+        label: t.transformer.name,
+      };
+    });
+
+    return (
+      <>
+        {transformations.map((t, i) => {
+          let editor, input;
+          if (t.id === DataTransformerID.noop) {
+            return (
+              <Select
+                className={css`
+                  margin-bottom: 10px;
+                `}
+                key={`${t.id}-${i}`}
+                options={availableTransformers}
+                placeholder="Select transformation"
+                onChange={v => {
+                  this.onTransformationChange(i, {
+                    id: v.value as string,
+                    options: {},
+                  });
+                }}
+              />
+            );
+          }
+          const transformationUI = transformersUIRegistry.getIfExists(t.id);
+          input = transformDataFrame(transformations.slice(0, i), preTransformData);
+
+          if (transformationUI) {
+            editor = React.createElement(transformationUI.component, {
+              options: { ...transformationUI.transformer.defaultOptions, ...t.options },
+              input,
+              onChange: (options: any) => {
+                this.onTransformationChange(i, {
+                  id: t.id,
+                  options,
+                });
+              },
+            });
+          }
+
+          return (
+            <TransformationRow
+              key={`${t.id}-${i}`}
+              input={input || []}
+              onRemove={() => this.onTransformationRemove(i)}
+              editor={editor}
+              name={transformationUI ? transformationUI.name : ''}
+              description={transformationUI ? transformationUI.description : ''}
+            />
+          );
+        })}
+      </>
+    );
+  };
+
+  render() {
+    return (
+      <>
+        {this.renderTransformationEditors()}
+        <Button variant="inverse" icon="fa fa-plus" onClick={this.onTransformationAdd}>
+          Add transformation
+        </Button>
+      </>
+    );
+  }
+}

+ 8 - 0
packages/grafana-ui/src/components/TransformersUI/transformers.ts

@@ -0,0 +1,8 @@
+import { Registry } from '@grafana/data';
+import { reduceTransformRegistryItem } from './ReduceTransformerEditor';
+import { filterFieldsByNameTransformRegistryItem } from './FilterByNameTransformerEditor';
+import { TransformerUIRegistyItem } from './types';
+
+export const transformersUIRegistry = new Registry<TransformerUIRegistyItem<any>>(() => {
+  return [reduceTransformRegistryItem, filterFieldsByNameTransformRegistryItem];
+});

+ 15 - 0
packages/grafana-ui/src/components/TransformersUI/types.ts

@@ -0,0 +1,15 @@
+import React from 'react';
+import { DataFrame, RegistryItem, DataTransformerInfo } from '@grafana/data';
+
+export interface TransformerUIRegistyItem<TOptions> extends RegistryItem {
+  component: React.ComponentType<TransformerUIProps<TOptions>>;
+  transformer: DataTransformerInfo<TOptions>;
+}
+
+export interface TransformerUIProps<T> {
+  // Transformer configuration, persisted on panel's model
+  options: T;
+  // Pre-transformation data frame
+  input: DataFrame[];
+  onChange: (options: T) => void;
+}

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

@@ -78,5 +78,10 @@ export { VariableSuggestion, VariableOrigin } from './DataLinks/DataLinkSuggesti
 export { DataLinksEditor } from './DataLinks/DataLinksEditor';
 export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu';
 export { SeriesIcon } from './Legend/SeriesIcon';
-
+export { transformersUIRegistry } from './TransformersUI/transformers';
+export { TransformationRow } from './TransformersUI/TransformationRow';
+export { TransformationsEditor } from './TransformersUI/TransformationsEditor';
+export { JSONFormatter } from './JSONFormatter/JSONFormatter';
+export { JsonExplorer } from './JSONFormatter/json_explorer/json_explorer';
 export { ErrorBoundary, ErrorBoundaryAlert } from './ErrorBoundary/ErrorBoundary';
+export { AlphaNotice } from './AlphaNotice/AlphaNotice';

+ 1 - 0
pkg/api/frontendsettings.go

@@ -195,6 +195,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
 			"env":           setting.Env,
 			"isEnterprise":  setting.IsEnterprise,
 		},
+		"featureToggles": hs.Cfg.FeatureToggles,
 	}
 
 	return jsonObj, nil

+ 13 - 0
pkg/setting/setting.go

@@ -266,6 +266,8 @@ type Cfg struct {
 	EditorsCanAdmin bool
 
 	ApiKeyMaxSecondsToLive int64
+
+	FeatureToggles map[string]bool
 }
 
 type CommandLineArgs struct {
@@ -941,6 +943,17 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	cfg.PluginsEnableAlpha = pluginsSection.Key("enable_alpha").MustBool(false)
 	cfg.PluginsAppsSkipVerifyTLS = pluginsSection.Key("app_tls_skip_verify_insecure").MustBool(false)
 
+	// Read and populate feature toggles list
+	featureTogglesSection := iniFile.Section("feature_toggles")
+	cfg.FeatureToggles = make(map[string]bool)
+	featuresTogglesStr, err := valueAsString(featureTogglesSection, "enable", "")
+	if err != nil {
+		return err
+	}
+	for _, feature := range util.SplitString(featuresTogglesStr) {
+		cfg.FeatureToggles[feature] = true
+	}
+
 	// check old location for this option
 	if panelsSection.Key("enable_alpha").MustBool(false) {
 		cfg.PluginsEnableAlpha = true

+ 1 - 1
public/app/core/components/jsontree/jsontree.ts

@@ -1,5 +1,5 @@
 import coreModule from 'app/core/core_module';
-import { JsonExplorer } from '../json_explorer/json_explorer';
+import { JsonExplorer } from '@grafana/ui';
 
 coreModule.directive('jsonTree', [
   function jsonTreeDirective() {

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

@@ -16,7 +16,7 @@ import './utils/outline';
 import './components/colorpicker/spectrum_picker';
 import './services/search_srv';
 import './services/ng_react';
-import { colors } from '@grafana/ui/';
+import { colors, JsonExplorer } from '@grafana/ui/';
 
 import { searchDirective } from './components/search/search';
 import { infoPopover } from './components/info_popover';
@@ -38,7 +38,6 @@ import { assignModelProperties } from './utils/model_utils';
 import { contextSrv } from './services/context_srv';
 import { KeybindingSrv } from './services/keybindingSrv';
 import { helpModal } from './components/help/help';
-import { JsonExplorer } from './components/json_explorer/json_explorer';
 import { NavModelSrv } from './nav_model_srv';
 import { geminiScrollbar } from './components/scroll/scroll';
 import { orgSwitcher } from './components/org_switcher';

+ 1 - 2
public/app/features/alerting/TestRuleResult.tsx

@@ -1,10 +1,9 @@
 import React, { PureComponent } from 'react';
-import { JSONFormatter } from 'app/core/components/JSONFormatter/JSONFormatter';
 import appEvents from 'app/core/app_events';
 import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
 import { getBackendSrv } from '@grafana/runtime';
 import { DashboardModel } from '../dashboard/state/DashboardModel';
-import { LoadingPlaceholder } from '@grafana/ui';
+import { LoadingPlaceholder, JSONFormatter } from '@grafana/ui';
 
 export interface Props {
   panelId: number;

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

@@ -172,7 +172,6 @@ export class PanelChrome extends PureComponent<Props, State> {
       if (!this.querySubscription) {
         this.querySubscription = queryRunner.subscribe(this.panelDataObserver);
       }
-
       queryRunner.run({
         datasource: panel.datasource,
         queries: panel.targets,
@@ -186,6 +185,7 @@ export class PanelChrome extends PureComponent<Props, State> {
         minInterval: panel.interval,
         scopedVars: panel.scopedVars,
         cacheTimeout: panel.cacheTimeout,
+        transformations: panel.transformations,
       });
     }
   };

+ 66 - 28
public/app/features/dashboard/panel_editor/QueriesTab.tsx

@@ -1,13 +1,14 @@
 // Libraries
 import React, { PureComponent } from 'react';
 import _ from 'lodash';
+import { css } from 'emotion';
 
 // Components
 import { EditorTabBody, EditorToolbarView } from './EditorTabBody';
 import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
 import { QueryInspector } from './QueryInspector';
 import { QueryOptions } from './QueryOptions';
-import { PanelOptionsGroup } from '@grafana/ui';
+import { PanelOptionsGroup, TransformationsEditor } from '@grafana/ui';
 import { QueryEditorRow } from './QueryEditorRow';
 
 // Services
@@ -18,8 +19,8 @@ import config from 'app/core/config';
 // Types
 import { PanelModel } from '../state/PanelModel';
 import { DashboardModel } from '../state/DashboardModel';
-import { DataQuery, DataSourceSelectItem, PanelData } from '@grafana/ui';
-import { LoadingState } from '@grafana/data';
+import { DataQuery, DataSourceSelectItem, PanelData, AlphaNotice, PluginState } from '@grafana/ui';
+import { LoadingState, DataTransformerConfig } from '@grafana/data';
 import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
 import { PanelQueryRunnerFormat } from '../state/PanelQueryRunner';
 import { Unsubscribable } from 'rxjs';
@@ -215,15 +216,24 @@ export class QueriesTab extends PureComponent<Props, State> {
     this.forceUpdate();
   };
 
+  onTransformersChange = (transformers: DataTransformerConfig[]) => {
+    this.props.panel.setTransformations(transformers);
+    this.forceUpdate();
+  };
+
   setScrollTop = (event: React.MouseEvent<HTMLElement>) => {
     const target = event.target as HTMLElement;
     this.setState({ scrollTop: target.scrollTop });
   };
 
+  getCurrentData = (applyTransformations = true) => {
+    const queryRunner = this.props.panel.getQueryRunner();
+    return queryRunner.getCurrentData(applyTransformations).series;
+  };
+
   render() {
     const { panel, dashboard } = this.props;
     const { currentDS, scrollTop, data } = this.state;
-
     const queryInspector: EditorToolbarView = {
       title: 'Query Inspector',
       render: this.renderQueryInspector,
@@ -235,6 +245,8 @@ export class QueriesTab extends PureComponent<Props, State> {
       render: this.renderHelp,
     };
 
+    const enableTransformations = config.featureToggles.transformations;
+
     return (
       <EditorTabBody
         heading="Query"
@@ -243,32 +255,58 @@ export class QueriesTab extends PureComponent<Props, State> {
         setScrollTop={this.setScrollTop}
         scrollTop={scrollTop}
       >
-        {isSharedDashboardQuery(currentDS.name) ? (
-          <DashboardQueryEditor panel={panel} panelData={data} onChange={query => this.onQueryChange(query, 0)} />
-        ) : (
-          <>
-            <div className="query-editor-rows">
-              {panel.targets.map((query, index) => (
-                <QueryEditorRow
-                  dataSourceValue={query.datasource || panel.datasource}
-                  key={query.refId}
-                  panel={panel}
-                  dashboard={dashboard}
-                  data={data}
-                  query={query}
-                  onChange={query => this.onQueryChange(query, index)}
-                  onRemoveQuery={this.onRemoveQuery}
-                  onAddQuery={this.onAddQuery}
-                  onMoveQuery={this.onMoveQuery}
-                  inMixedMode={currentDS.meta.mixed}
+        <>
+          {isSharedDashboardQuery(currentDS.name) ? (
+            <DashboardQueryEditor panel={panel} panelData={data} onChange={query => this.onQueryChange(query, 0)} />
+          ) : (
+            <>
+              <div className="query-editor-rows">
+                {panel.targets.map((query, index) => (
+                  <QueryEditorRow
+                    dataSourceValue={query.datasource || panel.datasource}
+                    key={query.refId}
+                    panel={panel}
+                    dashboard={dashboard}
+                    data={data}
+                    query={query}
+                    onChange={query => this.onQueryChange(query, index)}
+                    onRemoveQuery={this.onRemoveQuery}
+                    onAddQuery={this.onAddQuery}
+                    onMoveQuery={this.onMoveQuery}
+                    inMixedMode={currentDS.meta.mixed}
+                  />
+                ))}
+              </div>
+              <PanelOptionsGroup>
+                <QueryOptions panel={panel} datasource={currentDS} />
+              </PanelOptionsGroup>
+            </>
+          )}
+
+          {enableTransformations && (
+            <PanelOptionsGroup
+              title={
+                <>
+                  Transform query results
+                  <AlphaNotice
+                    state={PluginState.alpha}
+                    className={css`
+                      margin-left: 16px;
+                    `}
+                  />
+                </>
+              }
+            >
+              {this.state.data.state !== LoadingState.NotStarted && (
+                <TransformationsEditor
+                  transformations={this.props.panel.transformations || []}
+                  onChange={this.onTransformersChange}
+                  getCurrentData={this.getCurrentData}
                 />
-              ))}
-            </div>
-            <PanelOptionsGroup>
-              <QueryOptions panel={panel} datasource={currentDS} />
+              )}
             </PanelOptionsGroup>
-          </>
-        )}
+          )}
+        </>
       </EditorTabBody>
     );
   }

+ 1 - 2
public/app/features/dashboard/panel_editor/QueryInspector.tsx

@@ -1,8 +1,7 @@
 import React, { PureComponent } from 'react';
-import { JSONFormatter } from 'app/core/components/JSONFormatter/JSONFormatter';
 import appEvents from 'app/core/app_events';
 import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
-import { LoadingPlaceholder } from '@grafana/ui';
+import { LoadingPlaceholder, JSONFormatter } from '@grafana/ui';
 
 interface DsQuery {
   isLoading: boolean;

+ 11 - 2
public/app/features/dashboard/state/PanelModel.ts

@@ -7,7 +7,7 @@ import { getNextRefIdChar } from 'app/core/utils/query';
 
 // Types
 import { DataQuery, ScopedVars, DataQueryResponseData, PanelPlugin } from '@grafana/ui';
-import { DataLink } from '@grafana/data';
+import { DataLink, DataTransformerConfig } from '@grafana/data';
 
 import config from 'app/core/config';
 
@@ -66,6 +66,7 @@ const mustKeepProps: { [str: string]: boolean } = {
   transparent: true,
   pluginVersion: true,
   queryRunner: true,
+  transformations: true,
 };
 
 const defaults: any = {
@@ -93,6 +94,7 @@ export class PanelModel {
   panels?: any;
   soloMode?: boolean;
   targets: DataQuery[];
+  transformations?: DataTransformerConfig[];
   datasource: string;
   thresholds?: any;
   pluginVersion?: string;
@@ -290,7 +292,6 @@ export class PanelModel {
       } else if (oldOptions && oldOptions.options) {
         old = oldOptions.options;
       }
-
       this.options = this.options || {};
       Object.assign(this.options, newPlugin.onPanelTypeChanged(this.options, oldPluginId, old));
     }
@@ -344,6 +345,14 @@ export class PanelModel {
       this.queryRunner = null;
     }
   }
+
+  setTransformations(transformations: DataTransformerConfig[]) {
+    // save for persistence
+    this.transformations = transformations;
+
+    // update query runner transformers
+    this.getQueryRunner().setTransform(transformations);
+  }
 }
 
 function getPluginVersion(plugin: PanelPlugin): string {

+ 51 - 8
public/app/features/dashboard/state/PanelQueryRunner.ts

@@ -13,7 +13,8 @@ import { isSharedDashboardQuery, SharedQueryRunner } from 'app/plugins/datasourc
 // Types
 import { PanelData, DataQuery, ScopedVars, DataQueryRequest, DataSourceApi, DataSourceJsonData } from '@grafana/ui';
 
-import { TimeRange } from '@grafana/data';
+import { TimeRange, DataTransformerConfig, transformDataFrame, toLegacyResponseData } from '@grafana/data';
+import config from 'app/core/config';
 
 export interface QueryRunnerOptions<
   TQuery extends DataQuery = DataQuery,
@@ -32,6 +33,7 @@ export interface QueryRunnerOptions<
   scopedVars?: ScopedVars;
   cacheTimeout?: string;
   delayStateNotification?: number; // default 100ms.
+  transformations?: DataTransformerConfig[];
 }
 
 export enum PanelQueryRunnerFormat {
@@ -49,6 +51,7 @@ export class PanelQueryRunner {
   private subject?: Subject<PanelData>;
 
   private state = new PanelQueryState();
+  private transformations?: DataTransformerConfig[];
 
   // Listen to another panel for changes
   private sharedQueryRunner: SharedQueryRunner;
@@ -62,6 +65,27 @@ export class PanelQueryRunner {
     return this.panelId;
   }
 
+  /**
+   * Get the last result -- optionally skip the transformation
+   */
+  //  TODO: add tests
+  getCurrentData(transform = true): PanelData {
+    const v = this.state.validateStreamsAndGetPanelData();
+    const transformData = config.featureToggles.transformations && transform;
+    const hasTransformations = this.transformations && this.transformations.length;
+
+    if (transformData && hasTransformations) {
+      const processed = transformDataFrame(this.transformations, v.series);
+      return {
+        ...v,
+        series: processed,
+        legacy: processed.map(p => toLegacyResponseData(p)),
+      };
+    }
+
+    return v;
+  }
+
   /**
    * Listen for updates to the PanelData.  If a query has already run for this panel,
    * the results will be immediatly passed to the observer
@@ -78,7 +102,9 @@ export class PanelQueryRunner {
 
     // Send the last result
     if (this.state.isStarted()) {
-      observer.next(this.state.getDataAfterCheckingFormats());
+      // Force check formats again?
+      this.state.getDataAfterCheckingFormats();
+      observer.next(this.getCurrentData()); // transformed
     }
 
     return this.subject.subscribe(observer);
@@ -98,9 +124,17 @@ export class PanelQueryRunner {
     return this.subscribe(runner.subject, format);
   }
 
-  getCurrentData(): PanelData {
-    return this.state.validateStreamsAndGetPanelData();
-  }
+  /**
+   * Change the current transformation and notify all listeners
+   * Should be used only by panel editor to update the transformers
+   */
+  setTransform = (transformations?: DataTransformerConfig[]) => {
+    this.transformations = transformations;
+
+    if (this.state.isStarted()) {
+      this.onStreamingDataUpdated();
+    }
+  };
 
   async run(options: QueryRunnerOptions): Promise<PanelData> {
     const { state } = this;
@@ -200,13 +234,14 @@ export class PanelQueryRunner {
         }
       }, delayStateNotification || 500);
 
-      const data = await state.execute(ds, request);
+      this.transformations = options.transformations;
 
+      const data = await state.execute(ds, request);
       // Clear the delayed loading state timeout
       clearTimeout(loadingStateTimeoutId);
 
       // Broadcast results
-      this.subject.next(data);
+      this.subject.next(this.getCurrentData());
       return data;
     } catch (err) {
       clearTimeout(loadingStateTimeoutId);
@@ -223,7 +258,7 @@ export class PanelQueryRunner {
    */
   onStreamingDataUpdated = throttle(
     () => {
-      this.subject.next(this.state.validateStreamsAndGetPanelData());
+      this.subject.next(this.getCurrentData());
     },
     50,
     { trailing: true, leading: true }
@@ -241,6 +276,14 @@ export class PanelQueryRunner {
     // Will cancel and disconnect any open requets
     this.state.cancel('destroy');
   }
+
+  setState = (state: PanelQueryState) => {
+    this.state = state;
+  };
+
+  getState = () => {
+    return this.state;
+  };
 }
 
 async function getDataSource(

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

@@ -212,6 +212,7 @@ class MetricsPanelCtrl extends PanelCtrl {
       minInterval: panel.interval,
       scopedVars: panel.scopedVars,
       cacheTimeout: panel.cacheTimeout,
+      transformations: panel.transformations,
     });
   }
 

+ 10 - 26
public/app/features/plugins/PluginStateInfo.tsx

@@ -1,13 +1,12 @@
-import React, { FC, useContext } from 'react';
+import React, { FC } from 'react';
+import { PluginState, AlphaNotice } from '@grafana/ui';
 import { css } from 'emotion';
-import { PluginState, Tooltip, ThemeContext } from '@grafana/ui';
-import { PopoverContent } from '@grafana/ui/src/components/Tooltip/Tooltip';
 
 interface Props {
   state?: PluginState;
 }
 
-function getPluginStateInfoText(state?: PluginState): PopoverContent | null {
+function getPluginStateInfoText(state?: PluginState): JSX.Element | null {
   switch (state) {
     case PluginState.alpha:
       return (
@@ -30,30 +29,15 @@ function getPluginStateInfoText(state?: PluginState): PopoverContent | null {
 
 const PluginStateinfo: FC<Props> = props => {
   const text = getPluginStateInfoText(props.state);
-  if (!text) {
-    return null;
-  }
-
-  const theme = useContext(ThemeContext);
-
-  const styles = css`
-    background: linear-gradient(to bottom, ${theme.colors.blueBase}, ${theme.colors.blueShade});
-    color: ${theme.colors.gray7};
-    white-space: nowrap;
-    border-radius: 3px;
-    text-shadow: none;
-    font-size: 13px;
-    padding: 4px 8px;
-    margin-left: 16px;
-    cursor: help;
-  `;
 
   return (
-    <Tooltip content={text} theme={'info'} placement={'top'}>
-      <div className={styles}>
-        <i className="fa fa-warning" /> {props.state}
-      </div>
-    </Tooltip>
+    <AlphaNotice
+      state={props.state}
+      text={text}
+      className={css`
+        margin-left: 16px;
+      `}
+    />
   );
 };
 

+ 1 - 0
public/app/plugins/datasource/dashboard/SharedQueryRunner.ts

@@ -57,6 +57,7 @@ export class SharedQueryRunner {
       this.listenToPanelId = panelId;
       this.listenToRunner = this.listenToPanel.getQueryRunner();
       this.subscription = this.listenToRunner.chain(this.runner);
+      this.runner.setState(this.listenToRunner.getState());
       console.log('Connecting panel: ', this.containerPanel.id, 'to:', this.listenToPanelId);
     }
 

+ 1 - 1
public/app/plugins/panel/graph/module.ts

@@ -149,7 +149,7 @@ class GraphCtrl extends MetricsPanelCtrl {
 
     this.events.on('render', this.onRender.bind(this));
     this.events.on('data-received', this.onDataReceived.bind(this));
-    this.events.on('data-frames-received', this.onDataReceived.bind(this));
+    this.events.on('data-frames-received', this.onDataFramesReceived.bind(this));
     this.events.on('data-error', this.onDataError.bind(this));
     this.events.on('data-snapshot-load', this.onDataSnapshotLoad.bind(this));
     this.events.on('init-edit-mode', this.onInitEditMode.bind(this));