Bläddra i källkod

refactor: Merge PanelChrome and DataPanel, do query execution in PanelQueryRunner (#16632)

Moves query execution logic to PanelQueryRunner and structures PanelChrome so it subscribes to the query results rather than necessarily controlling their execution.
Ryan McKinley 6 år sedan
förälder
incheckning
ed0192104c

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

@@ -1,7 +1,7 @@
 import { ComponentClass } from 'react';
 import { LoadingState, SeriesData } from './data';
 import { TimeRange } from './time';
-import { ScopedVars, DataRequestInfo, DataQueryError } from './datasource';
+import { ScopedVars, DataRequestInfo, DataQueryError, LegacyResponseData } from './datasource';
 
 export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string;
 
@@ -10,6 +10,9 @@ export interface PanelData {
   series: SeriesData[];
   request?: DataRequestInfo;
   error?: DataQueryError;
+
+  // Data format expected by Angular panels
+  legacy?: LegacyResponseData[];
 }
 
 export interface PanelProps<T = any> {

+ 19 - 7
public/app/features/dashboard/dashgrid/DashboardPanel.tsx

@@ -19,6 +19,7 @@ import { PanelResizer } from './PanelResizer';
 import { PanelModel, DashboardModel } from '../state';
 import { PanelPlugin } from 'app/types';
 import { AngularPanelPlugin, ReactPanelPlugin } from '@grafana/ui/src/types/panel';
+import { AutoSizer } from 'react-virtualized';
 
 export interface Props {
   panel: PanelModel;
@@ -153,13 +154,24 @@ export class DashboardPanel extends PureComponent<Props, State> {
     const { plugin } = this.state;
 
     return (
-      <PanelChrome
-        plugin={plugin}
-        panel={panel}
-        dashboard={dashboard}
-        isFullscreen={isFullscreen}
-        isEditing={isEditing}
-      />
+      <AutoSizer>
+        {({ width, height }) => {
+          if (width === 0) {
+            return null;
+          }
+          return (
+            <PanelChrome
+              plugin={plugin}
+              panel={panel}
+              dashboard={dashboard}
+              isFullscreen={isFullscreen}
+              isEditing={isEditing}
+              width={width}
+              height={height}
+            />
+          );
+        }}
+      </AutoSizer>
     );
   }
 

+ 0 - 253
public/app/features/dashboard/dashgrid/DataPanel.tsx

@@ -1,253 +0,0 @@
-// Library
-import React, { Component } from 'react';
-
-// Services
-import { DatasourceSrv, getDatasourceSrv } from 'app/features/plugins/datasource_srv';
-// Utils
-import kbn from 'app/core/utils/kbn';
-// Types
-import {
-  DataQueryError,
-  LoadingState,
-  SeriesData,
-  TimeRange,
-  ScopedVars,
-  toSeriesData,
-  guessFieldTypes,
-  DataQuery,
-  PanelData,
-  DataRequestInfo,
-} from '@grafana/ui';
-
-interface RenderProps {
-  data: PanelData;
-}
-
-export interface Props {
-  datasource: string | null;
-  queries: DataQuery[];
-  panelId: number;
-  dashboardId?: number;
-  isVisible?: boolean;
-  timeRange?: TimeRange;
-  widthPixels: number;
-  refreshCounter: number;
-  minInterval?: string;
-  maxDataPoints?: number;
-  scopedVars?: ScopedVars;
-  children: (r: RenderProps) => JSX.Element;
-  onDataResponse?: (data?: SeriesData[]) => void;
-  onError: (message: string, error: DataQueryError) => void;
-}
-
-export interface State {
-  isFirstLoad: boolean;
-  data: PanelData;
-}
-
-/**
- * All panels will be passed tables that have our best guess at colum type set
- *
- * This is also used by PanelChrome for snapshot support
- */
-export function getProcessedSeriesData(results?: any[]): SeriesData[] {
-  if (!results) {
-    return [];
-  }
-
-  const series: SeriesData[] = [];
-  for (const r of results) {
-    if (r) {
-      series.push(guessFieldTypes(toSeriesData(r)));
-    }
-  }
-  return series;
-}
-
-export class DataPanel extends Component<Props, State> {
-  static defaultProps = {
-    isVisible: true,
-    dashboardId: 1,
-  };
-
-  dataSourceSrv: DatasourceSrv = getDatasourceSrv();
-  isUnmounted = false;
-
-  constructor(props: Props) {
-    super(props);
-
-    this.state = {
-      isFirstLoad: true,
-      data: {
-        state: LoadingState.NotStarted,
-        series: [],
-      },
-    };
-  }
-
-  componentDidMount() {
-    this.issueQueries();
-  }
-
-  componentWillUnmount() {
-    this.isUnmounted = true;
-  }
-
-  async componentDidUpdate(prevProps: Props) {
-    if (!this.hasPropsChanged(prevProps)) {
-      return;
-    }
-
-    this.issueQueries();
-  }
-
-  hasPropsChanged(prevProps: Props) {
-    return this.props.refreshCounter !== prevProps.refreshCounter;
-  }
-
-  private issueQueries = async () => {
-    const {
-      isVisible,
-      queries,
-      datasource,
-      panelId,
-      dashboardId,
-      timeRange,
-      widthPixels,
-      maxDataPoints,
-      scopedVars,
-      onDataResponse,
-      onError,
-    } = this.props;
-
-    if (!isVisible) {
-      return;
-    }
-
-    if (!queries.length) {
-      this.setState({
-        data: {
-          state: LoadingState.Done,
-          series: [],
-        },
-      });
-      return;
-    }
-
-    this.setState({
-      data: {
-        ...this.state.data,
-        loading: LoadingState.Loading,
-      },
-    });
-
-    try {
-      const ds = await this.dataSourceSrv.get(datasource, scopedVars);
-
-      const minInterval = this.props.minInterval || ds.interval;
-      const intervalRes = kbn.calculateInterval(timeRange, widthPixels, minInterval);
-
-      // make shallow copy of scoped vars,
-      // and add built in variables interval and interval_ms
-      const scopedVarsWithInterval = Object.assign({}, scopedVars, {
-        __interval: { text: intervalRes.interval, value: intervalRes.interval },
-        __interval_ms: { text: intervalRes.intervalMs.toString(), value: intervalRes.intervalMs },
-      });
-
-      const request: DataRequestInfo = {
-        timezone: 'browser',
-        panelId: panelId,
-        dashboardId: dashboardId,
-        range: timeRange,
-        rangeRaw: timeRange.raw,
-        interval: intervalRes.interval,
-        intervalMs: intervalRes.intervalMs,
-        targets: queries,
-        maxDataPoints: maxDataPoints || widthPixels,
-        scopedVars: scopedVarsWithInterval,
-        cacheTimeout: null,
-        startTime: Date.now(),
-      };
-
-      const resp = await ds.query(request);
-      request.endTime = Date.now();
-
-      if (this.isUnmounted) {
-        return;
-      }
-
-      // Make sure the data is SeriesData[]
-      const series = getProcessedSeriesData(resp.data);
-      if (onDataResponse) {
-        onDataResponse(series);
-      }
-
-      this.setState({
-        isFirstLoad: false,
-        data: {
-          state: LoadingState.Done,
-          series,
-          request,
-        },
-      });
-    } catch (err) {
-      console.log('DataPanel error', err);
-
-      let message = 'Query error';
-
-      if (err.message) {
-        message = err.message;
-      } else if (err.data && err.data.message) {
-        message = err.data.message;
-      } else if (err.data && err.data.error) {
-        message = err.data.error;
-      } else if (err.status) {
-        message = `Query error: ${err.status} ${err.statusText}`;
-      }
-
-      onError(message, err);
-
-      this.setState({
-        isFirstLoad: false,
-        data: {
-          ...this.state.data,
-          loading: LoadingState.Error,
-        },
-      });
-    }
-  };
-
-  render() {
-    const { queries } = this.props;
-    const { isFirstLoad, data } = this.state;
-    const { state } = data;
-
-    // do not render component until we have first data
-    if (isFirstLoad && (state === LoadingState.Loading || state === LoadingState.NotStarted)) {
-      return this.renderLoadingState();
-    }
-
-    if (!queries.length) {
-      return (
-        <div className="panel-empty">
-          <p>Add a query to get some data!</p>
-        </div>
-      );
-    }
-
-    return (
-      <>
-        {state === LoadingState.Loading && this.renderLoadingState()}
-        {this.props.children({ data })}
-      </>
-    );
-  }
-
-  private renderLoadingState(): JSX.Element {
-    return (
-      <div className="panel-loading">
-        <i className="fa fa-spinner fa-spin" />
-      </div>
-    );
-  }
-}

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

@@ -11,10 +11,8 @@ describe('PanelChrome', () => {
           bbb: { value: 'BBB', text: 'upperB' },
         },
       },
-      dashboard: {},
-      plugin: {},
       isFullscreen: false,
-    });
+    } as any);
   });
 
   it('Should replace a panel variable', () => {

+ 165 - 126
public/app/features/dashboard/dashgrid/PanelChrome.tsx

@@ -1,14 +1,12 @@
 // Libraries
 import React, { PureComponent } from 'react';
-import { AutoSizer } from 'react-virtualized';
 
 // Services
 import { getTimeSrv, TimeSrv } from '../services/TimeSrv';
 
 // Components
 import { PanelHeader } from './PanelHeader/PanelHeader';
-import { DataPanel } from './DataPanel';
-import ErrorBoundary from '../../../core/components/ErrorBoundary/ErrorBoundary';
+import ErrorBoundary from 'app/core/components/ErrorBoundary/ErrorBoundary';
 
 // Utils
 import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
@@ -19,12 +17,13 @@ import config from 'app/core/config';
 // Types
 import { DashboardModel, PanelModel } from '../state';
 import { PanelPlugin } from 'app/types';
-import { TimeRange, LoadingState, DataQueryError, SeriesData, toLegacyResponseData, PanelData } from '@grafana/ui';
+import { TimeRange, LoadingState, PanelData, toLegacyResponseData } from '@grafana/ui';
 import { ScopedVars } from '@grafana/ui';
 
 import templateSrv from 'app/features/templating/template_srv';
 
-import { getProcessedSeriesData } from './DataPanel';
+import { PanelQueryRunner, getProcessedSeriesData } from '../state/PanelQueryRunner';
+import { Unsubscribable } from 'rxjs';
 
 const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
 
@@ -34,53 +33,152 @@ export interface Props {
   plugin: PanelPlugin;
   isFullscreen: boolean;
   isEditing: boolean;
+  width: number;
+  height: number;
 }
 
 export interface State {
-  refreshCounter: number;
+  isFirstLoad: boolean;
   renderCounter: number;
   timeInfo?: string;
   timeRange?: TimeRange;
   errorMessage: string | null;
+
+  // Current state of all events
+  data: PanelData;
 }
 
 export class PanelChrome extends PureComponent<Props, State> {
   timeSrv: TimeSrv = getTimeSrv();
+  queryRunner = new PanelQueryRunner();
+  querySubscription: Unsubscribable;
 
-  constructor(props) {
+  constructor(props: Props) {
     super(props);
-
     this.state = {
-      refreshCounter: 0,
+      isFirstLoad: true,
       renderCounter: 0,
       errorMessage: null,
+      data: {
+        state: LoadingState.NotStarted,
+        series: [],
+      },
     };
+
+    // Listen for changes to the query results
+    this.querySubscription = this.queryRunner.subscribe(this.panelDataObserver);
   }
 
   componentDidMount() {
-    this.props.panel.events.on('refresh', this.onRefresh);
-    this.props.panel.events.on('render', this.onRender);
-    this.props.dashboard.panelInitialized(this.props.panel);
+    const { panel, dashboard } = this.props;
+    panel.events.on('refresh', this.onRefresh);
+    panel.events.on('render', this.onRender);
+    dashboard.panelInitialized(this.props.panel);
+
+    // Move snapshot data into the query response
+    if (this.hasPanelSnapshot) {
+      this.setState({
+        data: {
+          state: LoadingState.Done,
+          series: getProcessedSeriesData(panel.snapshotData),
+        },
+        isFirstLoad: false,
+      });
+    } else if (!this.wantsQueryExecution) {
+      this.setState({ isFirstLoad: false });
+    }
   }
 
   componentWillUnmount() {
     this.props.panel.events.off('refresh', this.onRefresh);
   }
 
+  // Updates the response with information from the stream
+  panelDataObserver = {
+    next: (data: PanelData) => {
+      if (data.state === LoadingState.Error) {
+        const { error } = data;
+        if (error) {
+          let message = 'Query error';
+          if (error.message) {
+            message = error.message;
+          } else if (error.data && error.data.message) {
+            message = error.data.message;
+          } else if (error.data && error.data.error) {
+            message = error.data.error;
+          } else if (error.status) {
+            message = `Query error: ${error.status} ${error.statusText}`;
+          }
+
+          if (this.state.errorMessage !== message) {
+            this.setState({ errorMessage: message });
+          }
+          // this event is used by old query editors
+          this.props.panel.events.emit('data-error', error);
+        }
+      } else {
+        this.clearErrorState();
+      }
+
+      // Save the query response into the panel
+      if (data.state === LoadingState.Done && this.props.dashboard.snapshot) {
+        this.props.panel.snapshotData = data.series;
+      }
+
+      this.setState({ data, isFirstLoad: false });
+
+      // Notify query editors that the results have changed
+      if (this.props.isEditing) {
+        const events = this.props.panel.events;
+        let legacy = data.legacy;
+        if (!legacy) {
+          legacy = data.series.map(v => toLegacyResponseData(v));
+        }
+
+        // Angular query editors expect TimeSeries|TableData
+        events.emit('data-received', legacy);
+
+        // Notify react query editors
+        events.emit('series-data-received', data);
+      }
+    },
+  };
+
   onRefresh = () => {
     console.log('onRefresh');
     if (!this.isVisible) {
       return;
     }
 
-    const { panel } = this.props;
+    const { panel, width } = this.props;
     const timeData = applyPanelTimeOverrides(panel, this.timeSrv.timeRange());
 
     this.setState({
-      refreshCounter: this.state.refreshCounter + 1,
       timeRange: timeData.timeRange,
       timeInfo: timeData.timeInfo,
     });
+
+    // Issue Query
+    if (this.wantsQueryExecution && !this.hasPanelSnapshot) {
+      if (width < 0) {
+        console.log('No width yet... wait till we know');
+        return;
+      }
+
+      this.queryRunner.run({
+        datasource: panel.datasource,
+        queries: panel.targets,
+        panelId: panel.id,
+        dashboardId: this.props.dashboard.id,
+        timezone: this.props.dashboard.timezone,
+        timeRange: timeData.timeRange,
+        widthPixels: width,
+        minInterval: undefined, // Currently not passed in DataPanel?
+        maxDataPoints: panel.maxDataPoints,
+        scopedVars: panel.scopedVars,
+        cacheTimeout: panel.cacheTimeout,
+      });
+    }
   };
 
   onRender = () => {
@@ -97,35 +195,6 @@ export class PanelChrome extends PureComponent<Props, State> {
     return templateSrv.replace(value, vars, format);
   };
 
-  onDataResponse = (data?: SeriesData[]) => {
-    if (this.props.dashboard.isSnapshot()) {
-      this.props.panel.snapshotData = data;
-    }
-    // clear error state (if any)
-    this.clearErrorState();
-
-    if (this.props.isEditing) {
-      const events = this.props.panel.events;
-      if (!data) {
-        data = [];
-      }
-
-      // Angular query editors expect TimeSeries|TableData
-      events.emit('data-received', data.map(v => toLegacyResponseData(v)));
-
-      // Notify react query editors
-      events.emit('series-data-received', data);
-    }
-  };
-
-  onDataError = (message: string, error: DataQueryError) => {
-    if (this.state.errorMessage !== message) {
-      this.setState({ errorMessage: message });
-    }
-    // this event is used by old query editors
-    this.props.panel.events.emit('data-error', error);
-  };
-
   onPanelError = (message: string) => {
     if (this.state.errorMessage !== message) {
       this.setState({ errorMessage: message });
@@ -147,112 +216,82 @@ export class PanelChrome extends PureComponent<Props, State> {
     return panel.snapshotData && panel.snapshotData.length;
   }
 
-  get needsQueryExecution() {
-    return this.hasPanelSnapshot || this.props.plugin.dataFormats.length > 0;
+  get wantsQueryExecution() {
+    return this.props.plugin.dataFormats.length > 0;
   }
 
-  get getDataForPanel() {
-    return {
-      state: LoadingState.Done,
-      series: this.hasPanelSnapshot ? getProcessedSeriesData(this.props.panel.snapshotData) : [],
-    };
-  }
-
-  renderPanelPlugin(data: PanelData, width: number, height: number): JSX.Element {
+  renderPanel(width: number, height: number): JSX.Element {
     const { panel, plugin } = this.props;
-    const { timeRange, renderCounter } = this.state;
+    const { timeRange, renderCounter, data, isFirstLoad } = this.state;
     const PanelComponent = plugin.reactPlugin.panel;
 
     // This is only done to increase a counter that is used by backend
     // image rendering (phantomjs/headless chrome) to know when to capture image
-    if (data.state === LoadingState.Done) {
+    const loading = data.state;
+    if (loading === LoadingState.Done) {
       profiler.renderingCompleted(panel.id);
     }
 
-    return (
-      <div className="panel-content">
-        <PanelComponent
-          data={data}
-          timeRange={timeRange}
-          options={panel.getOptions(plugin.reactPlugin.defaults)}
-          width={width - 2 * config.theme.panelPadding.horizontal}
-          height={height - PANEL_HEADER_HEIGHT - config.theme.panelPadding.vertical}
-          renderCounter={renderCounter}
-          replaceVariables={this.replaceVariables}
-        />
-      </div>
-    );
-  }
+    // do not render component until we have first data
+    if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) {
+      return this.renderLoadingState();
+    }
 
-  renderPanelBody = (width: number, height: number): JSX.Element => {
-    const { panel } = this.props;
-    const { refreshCounter, timeRange } = this.state;
-    const { datasource, targets } = panel;
     return (
       <>
-        {this.needsQueryExecution ? (
-          <DataPanel
-            panelId={panel.id}
-            datasource={datasource}
-            queries={targets}
+        {loading === LoadingState.Loading && this.renderLoadingState()}
+        <div className="panel-content">
+          <PanelComponent
+            data={data}
             timeRange={timeRange}
-            isVisible={this.isVisible}
-            widthPixels={width}
-            refreshCounter={refreshCounter}
-            scopedVars={panel.scopedVars}
-            onDataResponse={this.onDataResponse}
-            onError={this.onDataError}
-          >
-            {({ data }) => {
-              return this.renderPanelPlugin(data, width, height);
-            }}
-          </DataPanel>
-        ) : (
-          this.renderPanelPlugin(this.getDataForPanel, width, height)
-        )}
+            options={panel.getOptions(plugin.reactPlugin.defaults)}
+            width={width - 2 * config.theme.panelPadding.horizontal}
+            height={height - PANEL_HEADER_HEIGHT - config.theme.panelPadding.vertical}
+            renderCounter={renderCounter}
+            replaceVariables={this.replaceVariables}
+          />
+        </div>
       </>
     );
-  };
+  }
+
+  private renderLoadingState(): JSX.Element {
+    return (
+      <div className="panel-loading">
+        <i className="fa fa-spinner fa-spin" />
+      </div>
+    );
+  }
 
   render() {
-    const { dashboard, panel, isFullscreen } = this.props;
+    const { dashboard, panel, isFullscreen, width, height } = this.props;
     const { errorMessage, timeInfo } = this.state;
     const { transparent } = panel;
 
     const containerClassNames = `panel-container panel-container--absolute ${transparent ? 'panel-transparent' : ''}`;
     return (
-      <AutoSizer>
-        {({ width, height }) => {
-          if (width === 0) {
-            return null;
-          }
-
-          return (
-            <div className={containerClassNames}>
-              <PanelHeader
-                panel={panel}
-                dashboard={dashboard}
-                timeInfo={timeInfo}
-                title={panel.title}
-                description={panel.description}
-                scopedVars={panel.scopedVars}
-                links={panel.links}
-                error={errorMessage}
-                isFullscreen={isFullscreen}
-              />
-              <ErrorBoundary>
-                {({ error, errorInfo }) => {
-                  if (errorInfo) {
-                    this.onPanelError(error.message || DEFAULT_PLUGIN_ERROR);
-                    return null;
-                  }
-                  return this.renderPanelBody(width, height);
-                }}
-              </ErrorBoundary>
-            </div>
-          );
-        }}
-      </AutoSizer>
+      <div className={containerClassNames}>
+        <PanelHeader
+          panel={panel}
+          dashboard={dashboard}
+          timeInfo={timeInfo}
+          title={panel.title}
+          description={panel.description}
+          scopedVars={panel.scopedVars}
+          links={panel.links}
+          error={errorMessage}
+          isFullscreen={isFullscreen}
+        />
+        <ErrorBoundary>
+          {({ error, errorInfo }) => {
+            if (errorInfo) {
+              this.onPanelError(error.message || DEFAULT_PLUGIN_ERROR);
+              return null;
+            }
+            return this.renderPanel(width, height);
+          }}
+        </ErrorBoundary>
+      </div>
     );
   }
 }

+ 2 - 25
public/app/features/dashboard/dashgrid/DataPanel.test.tsx → public/app/features/dashboard/state/PanelQueryRunner.test.ts

@@ -1,29 +1,6 @@
-// Library
-import React from 'react';
-
-import { DataPanel, getProcessedSeriesData } from './DataPanel';
-
-describe('DataPanel', () => {
-  let dataPanel: DataPanel;
-
-  beforeEach(() => {
-    dataPanel = new DataPanel({
-      queries: [],
-      panelId: 1,
-      widthPixels: 100,
-      refreshCounter: 1,
-      datasource: 'xxx',
-      children: r => {
-        return <div>hello</div>;
-      },
-      onError: (message, error) => {},
-    });
-  });
-
-  it('starts with unloaded state', () => {
-    expect(dataPanel.state.isFirstLoad).toBe(true);
-  });
+import { getProcessedSeriesData } from './PanelQueryRunner';
 
+describe('QueryRunner', () => {
   it('converts timeseries to table skipping nulls', () => {
     const input1 = {
       target: 'Field Name',

+ 208 - 0
public/app/features/dashboard/state/PanelQueryRunner.ts

@@ -0,0 +1,208 @@
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { Subject, Unsubscribable, PartialObserver } from 'rxjs';
+import {
+  guessFieldTypes,
+  toSeriesData,
+  PanelData,
+  LoadingState,
+  DataQuery,
+  TimeRange,
+  ScopedVars,
+  DataRequestInfo,
+  SeriesData,
+  DataQueryError,
+  toLegacyResponseData,
+  isSeriesData,
+  DataSourceApi,
+} from '@grafana/ui';
+
+import cloneDeep from 'lodash/cloneDeep';
+
+import kbn from 'app/core/utils/kbn';
+
+export interface QueryRunnerOptions {
+  ds?: DataSourceApi; // if they already have the datasource, don't look it up
+  datasource: string | null;
+  queries: DataQuery[];
+  panelId: number;
+  dashboardId?: number;
+  timezone?: string;
+  timeRange?: TimeRange;
+  widthPixels: number;
+  minInterval?: string;
+  maxDataPoints?: number;
+  scopedVars?: ScopedVars;
+  cacheTimeout?: string;
+  delayStateNotification?: number; // default 100ms.
+}
+
+export enum PanelQueryRunnerFormat {
+  series = 'series',
+  legacy = 'legacy',
+}
+
+export class PanelQueryRunner {
+  private subject?: Subject<PanelData>;
+
+  private sendSeries = false;
+  private sendLegacy = false;
+
+  private data = {
+    state: LoadingState.NotStarted,
+    series: [],
+  } as PanelData;
+
+  /**
+   * Listen for updates to the PanelData.  If a query has already run for this panel,
+   * the results will be immediatly passed to the observer
+   */
+  subscribe(observer: PartialObserver<PanelData>, format = PanelQueryRunnerFormat.series): Unsubscribable {
+    if (!this.subject) {
+      this.subject = new Subject(); // Delay creating a subject until someone is listening
+    }
+
+    if (format === PanelQueryRunnerFormat.legacy) {
+      this.sendLegacy = true;
+    } else {
+      this.sendSeries = true;
+    }
+
+    // Send the last result
+    if (this.data.state !== LoadingState.NotStarted) {
+      // TODO: make sure it has legacy if necessary
+      observer.next(this.data);
+    }
+
+    return this.subject.subscribe(observer);
+  }
+
+  async run(options: QueryRunnerOptions): Promise<PanelData> {
+    if (!this.subject) {
+      this.subject = new Subject();
+    }
+
+    const {
+      queries,
+      timezone,
+      datasource,
+      panelId,
+      dashboardId,
+      timeRange,
+      cacheTimeout,
+      widthPixels,
+      maxDataPoints,
+      scopedVars,
+      delayStateNotification,
+    } = options;
+
+    const request: DataRequestInfo = {
+      timezone,
+      panelId,
+      dashboardId,
+      range: timeRange,
+      rangeRaw: timeRange.raw,
+      interval: '',
+      intervalMs: 0,
+      targets: cloneDeep(queries),
+      maxDataPoints: maxDataPoints || widthPixels,
+      scopedVars: scopedVars || {},
+      cacheTimeout,
+      startTime: Date.now(),
+    };
+
+    if (!queries) {
+      this.data = {
+        state: LoadingState.Done,
+        series: [], // Clear the data
+        legacy: [],
+        request,
+      };
+      this.subject.next(this.data);
+      return this.data;
+    }
+
+    try {
+      const ds = options.ds ? options.ds : await getDatasourceSrv().get(datasource, request.scopedVars);
+
+      const minInterval = options.minInterval || ds.interval;
+      const norm = kbn.calculateInterval(timeRange, widthPixels, minInterval);
+
+      // make shallow copy of scoped vars,
+      // and add built in variables interval and interval_ms
+      request.scopedVars = Object.assign({}, request.scopedVars, {
+        __interval: { text: norm.interval, value: norm.interval },
+        __interval_ms: { text: norm.intervalMs, value: norm.intervalMs },
+      });
+      request.interval = norm.interval;
+      request.intervalMs = norm.intervalMs;
+
+      // Send a loading status event on slower queries
+      setTimeout(() => {
+        if (!request.endTime) {
+          this.data = {
+            ...this.data,
+            state: LoadingState.Loading,
+            request,
+          };
+          this.subject.next(this.data);
+        }
+      }, delayStateNotification || 100);
+
+      const resp = await ds.query(request);
+      request.endTime = Date.now();
+
+      // Make sure the response is in a supported format
+      const series = this.sendSeries ? getProcessedSeriesData(resp.data) : [];
+      const legacy = this.sendLegacy
+        ? resp.data.map(v => {
+            if (isSeriesData(v)) {
+              return toLegacyResponseData(v);
+            }
+            return v;
+          })
+        : undefined;
+
+      // The Result
+      this.data = {
+        state: LoadingState.Done,
+        series,
+        legacy,
+        request,
+      };
+      this.subject.next(this.data);
+      return this.data;
+    } catch (err) {
+      const error = err as DataQueryError;
+      if (!error.message) {
+        err.message = 'Query Error';
+      }
+
+      this.data = {
+        ...this.data, // ?? Should we keep existing data, or clear it ???
+        state: LoadingState.Error,
+        error: error,
+      };
+      this.subject.next(this.data);
+      return this.data;
+    }
+  }
+}
+
+/**
+ * All panels will be passed tables that have our best guess at colum type set
+ *
+ * This is also used by PanelChrome for snapshot support
+ */
+export function getProcessedSeriesData(results?: any[]): SeriesData[] {
+  if (!results) {
+    return [];
+  }
+
+  const series: SeriesData[] = [];
+  for (const r of results) {
+    if (r) {
+      series.push(guessFieldTypes(toSeriesData(r)));
+    }
+  }
+  return series;
+}