Browse Source

Merge pull request #13787 from grafana/davkal/explore-transactions

Explore: query transactions for faster result display
David 7 years ago
parent
commit
7d0eccdd23

+ 2 - 8
public/app/core/utils/explore.test.ts

@@ -8,23 +8,17 @@ const DEFAULT_EXPLORE_STATE: ExploreState = {
   datasourceMissing: false,
   datasourceName: '',
   exploreDatasources: [],
-  graphResult: null,
+  graphRange: DEFAULT_RANGE,
   history: [],
-  latency: 0,
-  loading: false,
-  logsResult: null,
   queries: [],
-  queryErrors: [],
-  queryHints: [],
+  queryTransactions: [],
   range: DEFAULT_RANGE,
-  requestOptions: null,
   showingGraph: true,
   showingLogs: true,
   showingTable: true,
   supportsGraph: null,
   supportsLogs: null,
   supportsTable: null,
-  tableResult: null,
 };
 
 describe('state functions', () => {

+ 417 - 191
public/app/features/explore/Explore.tsx

@@ -1,8 +1,17 @@
 import React from 'react';
 import { hot } from 'react-hot-loader';
 import Select from 'react-select';
-
-import { ExploreState, ExploreUrlState, Query } from 'app/types/explore';
+import _ from 'lodash';
+
+import {
+  ExploreState,
+  ExploreUrlState,
+  HistoryItem,
+  Query,
+  QueryTransaction,
+  Range,
+  ResultType,
+} from 'app/types/explore';
 import kbn from 'app/core/utils/kbn';
 import colors from 'app/core/utils/colors';
 import store from 'app/core/store';
@@ -15,7 +24,6 @@ import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer'
 import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
 import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
 
-import ElapsedTime from './ElapsedTime';
 import QueryRows from './QueryRows';
 import Graph from './Graph';
 import Logs from './Logs';
@@ -25,16 +33,6 @@ import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
 
 const MAX_HISTORY_ITEMS = 100;
 
-function makeHints(hints) {
-  const hintsByIndex = [];
-  hints.forEach(hint => {
-    if (hint) {
-      hintsByIndex[hint.index] = hint;
-    }
-  });
-  return hintsByIndex;
-}
-
 function makeTimeSeriesList(dataList, options) {
   return dataList.map((seriesData, index) => {
     const datapoints = seriesData.datapoints || [];
@@ -53,6 +51,25 @@ function makeTimeSeriesList(dataList, options) {
   });
 }
 
+/**
+ * Update the query history. Side-effect: store history in local storage
+ */
+function updateHistory(history: HistoryItem[], datasourceId: string, queries: string[]): HistoryItem[] {
+  const ts = Date.now();
+  queries.forEach(query => {
+    history = [{ query, ts }, ...history];
+  });
+
+  if (history.length > MAX_HISTORY_ITEMS) {
+    history = history.slice(0, MAX_HISTORY_ITEMS);
+  }
+
+  // Combine all queries of a datasource type into one history
+  const historyKey = `grafana.explore.history.${datasourceId}`;
+  store.setObject(historyKey, history);
+  return history;
+}
+
 interface ExploreProps {
   datasourceSrv: any;
   onChangeSplit: (split: boolean, state?: ExploreState) => void;
@@ -83,6 +100,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     } else {
       const { datasource, queries, range } = props.urlState as ExploreUrlState;
       initialQueries = ensureQueries(queries);
+      const initialRange = range || { ...DEFAULT_RANGE };
       this.state = {
         datasource: null,
         datasourceError: null,
@@ -90,23 +108,17 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
         datasourceMissing: false,
         datasourceName: datasource,
         exploreDatasources: [],
-        graphResult: null,
+        graphRange: initialRange,
         history: [],
-        latency: 0,
-        loading: false,
-        logsResult: null,
         queries: initialQueries,
-        queryErrors: [],
-        queryHints: [],
-        range: range || { ...DEFAULT_RANGE },
-        requestOptions: null,
+        queryTransactions: [],
+        range: initialRange,
         showingGraph: true,
         showingLogs: true,
         showingTable: true,
         supportsGraph: null,
         supportsLogs: null,
         supportsTable: null,
-        tableResult: null,
       };
     }
     this.queryExpressions = initialQueries.map(q => q.query);
@@ -200,14 +212,32 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
   };
 
   onAddQueryRow = index => {
-    const { queries } = this.state;
+    // Local cache
     this.queryExpressions[index + 1] = '';
-    const nextQueries = [
-      ...queries.slice(0, index + 1),
-      { query: '', key: generateQueryKey() },
-      ...queries.slice(index + 1),
-    ];
-    this.setState({ queries: nextQueries });
+
+    this.setState(state => {
+      const { queries, queryTransactions } = state;
+
+      // Add row by generating new react key
+      const nextQueries = [
+        ...queries.slice(0, index + 1),
+        { query: '', key: generateQueryKey() },
+        ...queries.slice(index + 1),
+      ];
+
+      // Ongoing transactions need to update their row indices
+      const nextQueryTransactions = queryTransactions.map(qt => {
+        if (qt.rowIndex > index) {
+          return {
+            ...qt,
+            rowIndex: qt.rowIndex + 1,
+          };
+        }
+        return qt;
+      });
+
+      return { queries: nextQueries, queryTransactions: nextQueryTransactions };
+    });
   };
 
   onChangeDatasource = async option => {
@@ -215,12 +245,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
       datasource: null,
       datasourceError: null,
       datasourceLoading: true,
-      graphResult: null,
-      latency: 0,
-      logsResult: null,
-      queryErrors: [],
-      queryHints: [],
-      tableResult: null,
+      queryTransactions: [],
     });
     const datasourceName = option.value;
     const datasource = await this.props.datasourceSrv.get(datasourceName);
@@ -231,24 +256,25 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     // Keep current value in local cache
     this.queryExpressions[index] = value;
 
-    // Replace query row on override
     if (override) {
-      const { queries } = this.state;
-      const nextQuery: Query = {
-        key: generateQueryKey(index),
-        query: value,
-      };
-      const nextQueries = [...queries];
-      nextQueries[index] = nextQuery;
-
-      this.setState(
-        {
-          queryErrors: [],
-          queryHints: [],
+      this.setState(state => {
+        // Replace query row
+        const { queries, queryTransactions } = state;
+        const nextQuery: Query = {
+          key: generateQueryKey(index),
+          query: value,
+        };
+        const nextQueries = [...queries];
+        nextQueries[index] = nextQuery;
+
+        // Discard ongoing transaction related to row query
+        const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
+
+        return {
           queries: nextQueries,
-        },
-        this.onSubmit
-      );
+          queryTransactions: nextQueryTransactions,
+        };
+      }, this.onSubmit);
     }
   };
 
@@ -264,13 +290,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     this.queryExpressions = [''];
     this.setState(
       {
-        graphResult: null,
-        logsResult: null,
-        latency: 0,
         queries: ensureQueries(),
-        queryErrors: [],
-        queryHints: [],
-        tableResult: null,
+        queryTransactions: [],
       },
       this.saveState
     );
@@ -284,11 +305,41 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
   };
 
   onClickGraphButton = () => {
-    this.setState(state => ({ showingGraph: !state.showingGraph }));
+    this.setState(
+      state => {
+        const showingGraph = !state.showingGraph;
+        let nextQueryTransactions = state.queryTransactions;
+        if (!showingGraph) {
+          // Discard transactions related to Graph query
+          nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Graph');
+        }
+        return { queryTransactions: nextQueryTransactions, showingGraph };
+      },
+      () => {
+        if (this.state.showingGraph) {
+          this.onSubmit();
+        }
+      }
+    );
   };
 
   onClickLogsButton = () => {
-    this.setState(state => ({ showingLogs: !state.showingLogs }));
+    this.setState(
+      state => {
+        const showingLogs = !state.showingLogs;
+        let nextQueryTransactions = state.queryTransactions;
+        if (!showingLogs) {
+          // Discard transactions related to Logs query
+          nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Logs');
+        }
+        return { queryTransactions: nextQueryTransactions, showingLogs };
+      },
+      () => {
+        if (this.state.showingLogs) {
+          this.onSubmit();
+        }
+      }
+    );
   };
 
   onClickSplit = () => {
@@ -300,7 +351,22 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
   };
 
   onClickTableButton = () => {
-    this.setState(state => ({ showingTable: !state.showingTable }));
+    this.setState(
+      state => {
+        const showingTable = !state.showingTable;
+        let nextQueryTransactions = state.queryTransactions;
+        if (!showingTable) {
+          // Discard transactions related to Table query
+          nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table');
+        }
+        return { queryTransactions: nextQueryTransactions, showingTable };
+      },
+      () => {
+        if (this.state.showingTable) {
+          this.onSubmit();
+        }
+      }
+    );
   };
 
   onClickTableCell = (columnKey: string, rowValue: string) => {
@@ -308,39 +374,68 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
   };
 
   onModifyQueries = (action: object, index?: number) => {
-    const { datasource, queries } = this.state;
+    const { datasource } = this.state;
     if (datasource && datasource.modifyQuery) {
-      let nextQueries;
-      if (index === undefined) {
-        // Modify all queries
-        nextQueries = queries.map((q, i) => ({
-          key: generateQueryKey(i),
-          query: datasource.modifyQuery(this.queryExpressions[i], action),
-        }));
-      } else {
-        // Modify query only at index
-        nextQueries = [
-          ...queries.slice(0, index),
-          {
-            key: generateQueryKey(index),
-            query: datasource.modifyQuery(this.queryExpressions[index], action),
-          },
-          ...queries.slice(index + 1),
-        ];
-      }
-      this.queryExpressions = nextQueries.map(q => q.query);
-      this.setState({ queries: nextQueries }, () => this.onSubmit());
+      this.setState(
+        state => {
+          const { queries, queryTransactions } = state;
+          let nextQueries;
+          let nextQueryTransactions;
+          if (index === undefined) {
+            // Modify all queries
+            nextQueries = queries.map((q, i) => ({
+              key: generateQueryKey(i),
+              query: datasource.modifyQuery(this.queryExpressions[i], action),
+            }));
+            // Discard all ongoing transactions
+            nextQueryTransactions = [];
+          } else {
+            // Modify query only at index
+            nextQueries = [
+              ...queries.slice(0, index),
+              {
+                key: generateQueryKey(index),
+                query: datasource.modifyQuery(this.queryExpressions[index], action),
+              },
+              ...queries.slice(index + 1),
+            ];
+            // Discard transactions related to row query
+            nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
+          }
+          this.queryExpressions = nextQueries.map(q => q.query);
+          return {
+            queries: nextQueries,
+            queryTransactions: nextQueryTransactions,
+          };
+        },
+        () => this.onSubmit()
+      );
     }
   };
 
   onRemoveQueryRow = index => {
-    const { queries } = this.state;
-    if (queries.length <= 1) {
-      return;
-    }
-    const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)];
-    this.queryExpressions = nextQueries.map(q => q.query);
-    this.setState({ queries: nextQueries }, () => this.onSubmit());
+    // Remove from local cache
+    this.queryExpressions = [...this.queryExpressions.slice(0, index), ...this.queryExpressions.slice(index + 1)];
+
+    this.setState(
+      state => {
+        const { queries, queryTransactions } = state;
+        if (queries.length <= 1) {
+          return null;
+        }
+        // Remove row from react state
+        const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)];
+
+        // Discard transactions related to row query
+        const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
+
+        return {
+          queries: nextQueries,
+          queryTransactions: nextQueryTransactions,
+        };
+      },
+      () => this.onSubmit()
+    );
   };
 
   onSubmit = () => {
@@ -349,7 +444,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
       this.runTableQuery();
     }
     if (showingGraph && supportsGraph) {
-      this.runGraphQuery();
+      this.runGraphQueries();
     }
     if (showingLogs && supportsLogs) {
       this.runLogsQuery();
@@ -357,32 +452,11 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     this.saveState();
   };
 
-  onQuerySuccess(datasourceId: string, queries: string[]): void {
-    // save queries to history
-    let { history } = this.state;
-    const { datasource } = this.state;
-
-    if (datasource.meta.id !== datasourceId) {
-      // Navigated away, queries did not matter
-      return;
-    }
-
-    const ts = Date.now();
-    queries.forEach(query => {
-      history = [{ query, ts }, ...history];
-    });
-
-    if (history.length > MAX_HISTORY_ITEMS) {
-      history = history.slice(0, MAX_HISTORY_ITEMS);
-    }
-
-    // Combine all queries of a datasource type into one history
-    const historyKey = `grafana.explore.history.${datasourceId}`;
-    store.setObject(historyKey, history);
-    this.setState({ history });
-  }
-
-  buildQueryOptions(targetOptions: { format: string; hinting?: boolean; instant?: boolean }) {
+  buildQueryOptions(
+    query: string,
+    rowIndex: number,
+    targetOptions: { format: string; hinting?: boolean; instant?: boolean }
+  ) {
     const { datasource, range } = this.state;
     const resolution = this.el.offsetWidth;
     const absoluteRange = {
@@ -390,90 +464,235 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
       to: parseDate(range.to, true),
     };
     const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
-    const targets = this.queryExpressions.map((q, i) => ({
-      ...targetOptions,
-      // Target identifier is needed for table transformations
-      refId: i + 1,
-      expr: q,
-    }));
+    const targets = [
+      {
+        ...targetOptions,
+        // Target identifier is needed for table transformations
+        refId: rowIndex + 1,
+        expr: query,
+      },
+    ];
+
+    // Clone range for query request
+    const queryRange: Range = { ...range };
+
     return {
       interval,
-      range,
       targets,
+      range: queryRange,
     };
   }
 
-  async runGraphQuery() {
+  startQueryTransaction(query: string, rowIndex: number, resultType: ResultType, options: any): QueryTransaction {
+    const queryOptions = this.buildQueryOptions(query, rowIndex, options);
+    const transaction: QueryTransaction = {
+      query,
+      resultType,
+      rowIndex,
+      id: generateQueryKey(),
+      done: false,
+      latency: 0,
+      options: queryOptions,
+    };
+
+    // Using updater style because we might be modifying queryTransactions in quick succession
+    this.setState(state => {
+      const { queryTransactions } = state;
+      // Discarding existing transactions of same type
+      const remainingTransactions = queryTransactions.filter(
+        qt => !(qt.resultType === resultType && qt.rowIndex === rowIndex)
+      );
+
+      // Append new transaction
+      const nextQueryTransactions = [...remainingTransactions, transaction];
+
+      return {
+        queryTransactions: nextQueryTransactions,
+      };
+    });
+
+    return transaction;
+  }
+
+  completeQueryTransaction(
+    transactionId: string,
+    result: any,
+    latency: number,
+    queries: string[],
+    datasourceId: string
+  ) {
     const { datasource } = this.state;
+    if (datasource.meta.id !== datasourceId) {
+      // Navigated away, queries did not matter
+      return;
+    }
+
+    this.setState(state => {
+      const { history, queryTransactions } = state;
+
+      // Transaction might have been discarded
+      const transaction = queryTransactions.find(qt => qt.id === transactionId);
+      if (!transaction) {
+        return null;
+      }
+
+      // Get query hints
+      let hints;
+      if (datasource.getQueryHints) {
+        hints = datasource.getQueryHints(transaction.query, result);
+      }
+
+      // Mark transactions as complete
+      const nextQueryTransactions = queryTransactions.map(qt => {
+        if (qt.id === transactionId) {
+          return {
+            ...qt,
+            hints,
+            latency,
+            result,
+            done: true,
+          };
+        }
+        return qt;
+      });
+
+      const nextHistory = updateHistory(history, datasourceId, queries);
+
+      return {
+        history: nextHistory,
+        queryTransactions: nextQueryTransactions,
+      };
+    });
+  }
+
+  discardTransactions(rowIndex: number) {
+    this.setState(state => {
+      const remainingTransactions = state.queryTransactions.filter(qt => qt.rowIndex !== rowIndex);
+      return { queryTransactions: remainingTransactions };
+    });
+  }
+
+  failQueryTransaction(transactionId: string, error: string, datasourceId: string) {
+    const { datasource } = this.state;
+    if (datasource.meta.id !== datasourceId) {
+      // Navigated away, queries did not matter
+      return;
+    }
+
+    this.setState(state => {
+      // Transaction might have been discarded
+      if (!state.queryTransactions.find(qt => qt.id === transactionId)) {
+        return null;
+      }
+
+      // Mark transactions as complete
+      const nextQueryTransactions = state.queryTransactions.map(qt => {
+        if (qt.id === transactionId) {
+          return {
+            ...qt,
+            error,
+            done: true,
+          };
+        }
+        return qt;
+      });
+
+      return {
+        queryTransactions: nextQueryTransactions,
+      };
+    });
+  }
+
+  async runGraphQueries() {
     const queries = [...this.queryExpressions];
     if (!hasQuery(queries)) {
       return;
     }
-    this.setState({ latency: 0, loading: true, graphResult: null, queryErrors: [], queryHints: [] });
-    const now = Date.now();
-    const options = this.buildQueryOptions({ format: 'time_series', instant: false, hinting: true });
-    try {
-      const res = await datasource.query(options);
-      const result = makeTimeSeriesList(res.data, options);
-      const queryHints = res.hints ? makeHints(res.hints) : [];
-      const latency = Date.now() - now;
-      this.setState({ latency, loading: false, graphResult: result, queryHints, requestOptions: options });
-      this.onQuerySuccess(datasource.meta.id, queries);
-    } catch (response) {
-      console.error(response);
-      const queryError = response.data ? response.data.error : response;
-      this.setState({ loading: false, queryErrors: [queryError] });
-    }
+    const { datasource } = this.state;
+    const datasourceId = datasource.meta.id;
+    // Run all queries concurrently
+    queries.forEach(async (query, rowIndex) => {
+      if (query) {
+        const transaction = this.startQueryTransaction(query, rowIndex, 'Graph', {
+          format: 'time_series',
+          instant: false,
+        });
+        try {
+          const now = Date.now();
+          const res = await datasource.query(transaction.options);
+          const latency = Date.now() - now;
+          const results = makeTimeSeriesList(res.data, transaction.options);
+          this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
+          this.setState({ graphRange: transaction.options.range });
+        } catch (response) {
+          console.error(response);
+          const queryError = response.data ? response.data.error : response;
+          this.failQueryTransaction(transaction.id, queryError, datasourceId);
+        }
+      } else {
+        this.discardTransactions(rowIndex);
+      }
+    });
   }
 
   async runTableQuery() {
     const queries = [...this.queryExpressions];
-    const { datasource } = this.state;
     if (!hasQuery(queries)) {
       return;
     }
-    this.setState({ latency: 0, loading: true, queryErrors: [], queryHints: [], tableResult: null });
-    const now = Date.now();
-    const options = this.buildQueryOptions({
-      format: 'table',
-      instant: true,
+    const { datasource } = this.state;
+    const datasourceId = datasource.meta.id;
+    // Run all queries concurrently
+    queries.forEach(async (query, rowIndex) => {
+      if (query) {
+        const transaction = this.startQueryTransaction(query, rowIndex, 'Table', {
+          format: 'table',
+          instant: true,
+          valueWithRefId: true,
+        });
+        try {
+          const now = Date.now();
+          const res = await datasource.query(transaction.options);
+          const latency = Date.now() - now;
+          const results = res.data[0];
+          this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
+        } catch (response) {
+          console.error(response);
+          const queryError = response.data ? response.data.error : response;
+          this.failQueryTransaction(transaction.id, queryError, datasourceId);
+        }
+      } else {
+        this.discardTransactions(rowIndex);
+      }
     });
-    try {
-      const res = await datasource.query(options);
-      const tableModel = mergeTablesIntoModel(new TableModel(), ...res.data);
-      const latency = Date.now() - now;
-      this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options });
-      this.onQuerySuccess(datasource.meta.id, queries);
-    } catch (response) {
-      console.error(response);
-      const queryError = response.data ? response.data.error : response;
-      this.setState({ loading: false, queryErrors: [queryError] });
-    }
   }
 
   async runLogsQuery() {
     const queries = [...this.queryExpressions];
-    const { datasource } = this.state;
     if (!hasQuery(queries)) {
       return;
     }
-    this.setState({ latency: 0, loading: true, queryErrors: [], queryHints: [], logsResult: null });
-    const now = Date.now();
-    const options = this.buildQueryOptions({
-      format: 'logs',
+    const { datasource } = this.state;
+    const datasourceId = datasource.meta.id;
+    // Run all queries concurrently
+    queries.forEach(async (query, rowIndex) => {
+      if (query) {
+        const transaction = this.startQueryTransaction(query, rowIndex, 'Logs', { format: 'logs' });
+        try {
+          const now = Date.now();
+          const res = await datasource.query(transaction.options);
+          const latency = Date.now() - now;
+          const results = res.data;
+          this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
+        } catch (response) {
+          console.error(response);
+          const queryError = response.data ? response.data.error : response;
+          this.failQueryTransaction(transaction.id, queryError, datasourceId);
+        }
+      } else {
+        this.discardTransactions(rowIndex);
+      }
     });
-
-    try {
-      const res = await datasource.query(options);
-      const logsData = res.data;
-      const latency = Date.now() - now;
-      this.setState({ latency, loading: false, logsResult: logsData, requestOptions: options });
-      this.onQuerySuccess(datasource.meta.id, queries);
-    } catch (response) {
-      console.error(response);
-      const queryError = response.data ? response.data.error : response;
-      this.setState({ loading: false, queryErrors: [queryError] });
-    }
   }
 
   request = url => {
@@ -485,6 +704,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     // Copy state, but copy queries including modifications
     return {
       ...this.state,
+      queryTransactions: [],
       queries: ensureQueries(this.queryExpressions.map(query => ({ query }))),
     };
   }
@@ -502,23 +722,17 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
       datasourceLoading,
       datasourceMissing,
       exploreDatasources,
-      graphResult,
+      graphRange,
       history,
-      latency,
-      loading,
-      logsResult,
       queries,
-      queryErrors,
-      queryHints,
+      queryTransactions,
       range,
-      requestOptions,
       showingGraph,
       showingLogs,
       showingTable,
       supportsGraph,
       supportsLogs,
       supportsTable,
-      tableResult,
     } = this.state;
     const showingBoth = showingGraph && showingTable;
     const graphHeight = showingBoth ? '200px' : '400px';
@@ -527,6 +741,20 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     const tableButtonActive = showingBoth || showingTable ? 'active' : '';
     const exploreClass = split ? 'explore explore-split' : 'explore';
     const selectedDatasource = datasource ? exploreDatasources.find(d => d.label === datasource.name) : undefined;
+    const graphLoading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done);
+    const tableLoading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done);
+    const logsLoading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done);
+    const graphResult = _.flatten(
+      queryTransactions.filter(qt => qt.resultType === 'Graph' && qt.done && qt.result).map(qt => qt.result)
+    );
+    const tableResult = mergeTablesIntoModel(
+      new TableModel(),
+      ...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done).map(qt => qt.result)
+    );
+    const logsResult = _.flatten(
+      queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done).map(qt => qt.result)
+    );
+    const loading = queryTransactions.some(qt => !qt.done);
 
     return (
       <div className={exploreClass} ref={this.getRef}>
@@ -584,9 +812,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
           </div>
           <div className="navbar-buttons relative">
             <button className="btn navbar-button--primary" onClick={this.onSubmit}>
-              Run Query <i className="fa fa-level-down run-icon" />
+              Run Query{' '}
+              {loading ? <i className="fa fa-spinner fa-spin run-icon" /> : <i className="fa fa-level-down run-icon" />}
             </button>
-            {loading || latency ? <ElapsedTime time={latency} className="text-info" /> : null}
           </div>
         </div>
 
@@ -605,8 +833,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
             <QueryRows
               history={history}
               queries={queries}
-              queryErrors={queryErrors}
-              queryHints={queryHints}
               request={this.request}
               onAddQueryRow={this.onAddQueryRow}
               onChangeQuery={this.onChangeQuery}
@@ -614,6 +840,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
               onExecuteQuery={this.onSubmit}
               onRemoveQueryRow={this.onRemoveQueryRow}
               supportsLogs={supportsLogs}
+              transactions={queryTransactions}
             />
             <div className="result-options">
               {supportsGraph ? (
@@ -635,23 +862,22 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
 
             <main className="m-t-2">
               {supportsGraph &&
-                showingGraph &&
-                graphResult && (
+                showingGraph && (
                   <Graph
                     data={graphResult}
                     height={graphHeight}
-                    loading={loading}
+                    loading={graphLoading}
                     id={`explore-graph-${position}`}
-                    options={requestOptions}
+                    range={graphRange}
                     split={split}
                   />
                 )}
               {supportsTable && showingTable ? (
-                <div className="panel-container">
-                  <Table data={tableResult} loading={loading} onClickCell={this.onClickTableCell} />
+                <div className="panel-container m-t-2">
+                  <Table data={tableResult} loading={tableLoading} onClickCell={this.onClickTableCell} />
                 </div>
               ) : null}
-              {supportsLogs && showingLogs ? <Logs data={logsResult} loading={loading} /> : null}
+              {supportsLogs && showingLogs ? <Logs data={logsResult} loading={logsLoading} /> : null}
             </main>
           </div>
         ) : null}

+ 5 - 18
public/app/features/explore/Graph.test.tsx

@@ -4,24 +4,11 @@ import { Graph } from './Graph';
 import { mockData } from './__mocks__/mockData';
 
 const setup = (propOverrides?: object) => {
-  const props = Object.assign(
-    {
-      data: mockData().slice(0, 19),
-      options: {
-        interval: '20s',
-        range: { from: 'now-6h', to: 'now' },
-        targets: [
-          {
-            format: 'time_series',
-            instant: false,
-            hinting: true,
-            expr: 'prometheus_http_request_duration_seconds_bucket',
-          },
-        ],
-      },
-    },
-    propOverrides
-  );
+  const props = {
+    data: mockData().slice(0, 19),
+    range: { from: 'now-6h', to: 'now' },
+    ...propOverrides,
+  };
 
   // Enzyme.shallow did not work well with jquery.flop. Mocking the draw function.
   Graph.prototype.draw = jest.fn();

+ 19 - 23
public/app/features/explore/Graph.tsx

@@ -5,6 +5,8 @@ import { withSize } from 'react-sizeme';
 
 import 'vendor/flot/jquery.flot';
 import 'vendor/flot/jquery.flot.time';
+
+import { Range } from 'app/types/explore';
 import * as dateMath from 'app/core/utils/datemath';
 import TimeSeries from 'app/core/time_series2';
 
@@ -74,7 +76,7 @@ interface GraphProps {
   height?: string; // e.g., '200px'
   id?: string;
   loading?: boolean;
-  options: any;
+  range: Range;
   split?: boolean;
   size?: { width: number; height: number };
 }
@@ -101,7 +103,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
   componentDidUpdate(prevProps: GraphProps) {
     if (
       prevProps.data !== this.props.data ||
-      prevProps.options !== this.props.options ||
+      prevProps.range !== this.props.range ||
       prevProps.split !== this.props.split ||
       prevProps.height !== this.props.height ||
       (prevProps.size && prevProps.size.width !== this.props.size.width)
@@ -120,22 +122,22 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
   };
 
   draw() {
-    const { options: userOptions, size } = this.props;
+    const { range, size } = this.props;
     const data = this.getGraphData();
 
     const $el = $(`#${this.props.id}`);
-    if (!data) {
-      $el.empty();
-      return;
+    let series = [{ data: [[0, 0]] }];
+
+    if (data && data.length > 0) {
+      series = data.map((ts: TimeSeries) => ({
+        color: ts.color,
+        label: ts.label,
+        data: ts.getFlotPairs('null'),
+      }));
     }
-    const series = data.map((ts: TimeSeries) => ({
-      color: ts.color,
-      label: ts.label,
-      data: ts.getFlotPairs('null'),
-    }));
 
     const ticks = (size.width || 0) / 100;
-    let { from, to } = userOptions.range;
+    let { from, to } = range;
     if (!moment.isMoment(from)) {
       from = dateMath.parse(from, false);
     }
@@ -157,7 +159,6 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
     const options = {
       ...FLOT_OPTIONS,
       ...dynamicOptions,
-      ...userOptions,
     };
     $.plot($el, series, options);
   }
@@ -166,16 +167,10 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
     const { height = '100px', id = 'graph', loading = false } = this.props;
     const data = this.getGraphData();
 
-    if (!loading && data.length === 0) {
-      return (
-        <div className="panel-container">
-          <div className="muted m-a-1">The queries returned no time series to graph.</div>
-        </div>
-      );
-    }
     return (
-      <div>
-        {this.props.data.length > MAX_NUMBER_OF_TIME_SERIES &&
+      <>
+        {this.props.data &&
+          this.props.data.length > MAX_NUMBER_OF_TIME_SERIES &&
           !this.state.showAllTimeSeries && (
             <div className="time-series-disclaimer">
               <i className="fa fa-fw fa-warning disclaimer-icon" />
@@ -186,10 +181,11 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
             </div>
           )}
         <div className="panel-container">
+          {loading && <div className="explore-graph__loader" />}
           <div id={id} className="explore-graph" style={{ height }} />
           <Legend data={data} />
         </div>
-      </div>
+      </>
     );
   }
 }

+ 21 - 5
public/app/features/explore/QueryRows.tsx

@@ -1,7 +1,18 @@
 import React, { PureComponent } from 'react';
 
+import { QueryTransaction } from 'app/types/explore';
+
 // TODO make this datasource-plugin-dependent
 import QueryField from './PromQueryField';
+import QueryTransactions from './QueryTransactions';
+
+function getFirstHintFromTransactions(transactions: QueryTransaction[]) {
+  const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
+  if (transaction) {
+    return transaction.hints[0];
+  }
+  return undefined;
+}
 
 class QueryRow extends PureComponent<any, {}> {
   onChangeQuery = (value, override?: boolean) => {
@@ -44,13 +55,19 @@ class QueryRow extends PureComponent<any, {}> {
   };
 
   render() {
-    const { history, query, queryError, queryHint, request, supportsLogs } = this.props;
+    const { history, query, request, supportsLogs, transactions } = this.props;
+    const transactionWithError = transactions.find(t => t.error);
+    const hint = getFirstHintFromTransactions(transactions);
+    const queryError = transactionWithError ? transactionWithError.error : null;
     return (
       <div className="query-row">
+        <div className="query-row-status">
+          <QueryTransactions transactions={transactions} />
+        </div>
         <div className="query-row-field">
           <QueryField
             error={queryError}
-            hint={queryHint}
+            hint={hint}
             initialQuery={query}
             history={history}
             onClickHintFix={this.onClickHintFix}
@@ -78,7 +95,7 @@ class QueryRow extends PureComponent<any, {}> {
 
 export default class QueryRows extends PureComponent<any, {}> {
   render() {
-    const { className = '', queries, queryErrors, queryHints, ...handlers } = this.props;
+    const { className = '', queries, queryHints, transactions, ...handlers } = this.props;
     return (
       <div className={className}>
         {queries.map((q, index) => (
@@ -86,8 +103,7 @@ export default class QueryRows extends PureComponent<any, {}> {
             key={q.key}
             index={index}
             query={q.query}
-            queryError={queryErrors[index]}
-            queryHint={queryHints[index]}
+            transactions={transactions.filter(t => t.rowIndex === index)}
             {...handlers}
           />
         ))}

+ 42 - 0
public/app/features/explore/QueryTransactions.tsx

@@ -0,0 +1,42 @@
+import React, { PureComponent } from 'react';
+
+import { QueryTransaction as QueryTransactionModel } from 'app/types/explore';
+import ElapsedTime from './ElapsedTime';
+
+function formatLatency(value) {
+  return `${(value / 1000).toFixed(1)}s`;
+}
+
+interface QueryTransactionProps {
+  transaction: QueryTransactionModel;
+}
+
+class QueryTransaction extends PureComponent<QueryTransactionProps> {
+  render() {
+    const { transaction } = this.props;
+    const className = transaction.done ? 'query-transaction' : 'query-transaction query-transaction--loading';
+    return (
+      <div className={className}>
+        <div className="query-transaction__type">{transaction.resultType}:</div>
+        <div className="query-transaction__duration">
+          {transaction.done ? formatLatency(transaction.latency) : <ElapsedTime />}
+        </div>
+      </div>
+    );
+  }
+}
+
+interface QueryTransactionsProps {
+  transactions: QueryTransactionModel[];
+}
+
+export default class QueryTransactions extends PureComponent<QueryTransactionsProps> {
+  render() {
+    const { transactions } = this.props;
+    return (
+      <div className="query-transactions">
+        {transactions.map((t, i) => <QueryTransaction key={`${t.query}:${t.resultType}`} transaction={t} />)}
+      </div>
+    );
+  }
+}

+ 1 - 1
public/app/features/explore/Table.tsx

@@ -51,7 +51,7 @@ export default class Table extends PureComponent<TableProps> {
         minRows={0}
         noDataText={noDataText}
         resolveData={data => prepareRows(data, columnNames)}
-        showPagination={data}
+        showPagination={Boolean(data)}
       />
     );
   }

+ 19 - 10
public/app/features/explore/__snapshots__/Graph.test.tsx.snap

@@ -1,7 +1,7 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`Render should render component 1`] = `
-<div>
+<Fragment>
   <div
     className="panel-container"
   >
@@ -458,11 +458,11 @@ exports[`Render should render component 1`] = `
       }
     />
   </div>
-</div>
+</Fragment>
 `;
 
 exports[`Render should render component with disclaimer 1`] = `
-<div>
+<Fragment>
   <div
     className="time-series-disclaimer"
   >
@@ -956,17 +956,26 @@ exports[`Render should render component with disclaimer 1`] = `
       }
     />
   </div>
-</div>
+</Fragment>
 `;
 
 exports[`Render should show query return no time series 1`] = `
-<div
-  className="panel-container"
->
+<Fragment>
   <div
-    className="muted m-a-1"
+    className="panel-container"
   >
-    The queries returned no time series to graph.
+    <div
+      className="explore-graph"
+      id="graph"
+      style={
+        Object {
+          "height": "100px",
+        }
+      }
+    />
+    <Legend
+      data={Array []}
+    />
   </div>
-</div>
+</Fragment>
 `;

+ 6 - 8
public/app/plugins/datasource/prometheus/datasource.ts

@@ -176,7 +176,6 @@ export class PrometheusDatasource {
 
     return this.$q.all(allQueryPromise).then(responseList => {
       let result = [];
-      let hints = [];
 
       _.each(responseList, (response, index) => {
         if (response.status === 'error') {
@@ -196,19 +195,14 @@ export class PrometheusDatasource {
           end: queries[index].end,
           query: queries[index].expr,
           responseListLength: responseList.length,
-          responseIndex: index,
           refId: activeTargets[index].refId,
+          valueWithRefId: activeTargets[index].valueWithRefId,
         };
         const series = this.resultTransformer.transform(response, transformerOptions);
         result = [...result, ...series];
-
-        if (queries[index].hinting) {
-          const queryHints = getQueryHints(series, this);
-          hints = [...hints, ...queryHints];
-        }
       });
 
-      return { data: result, hints };
+      return { data: result };
     });
   }
 
@@ -437,6 +431,10 @@ export class PrometheusDatasource {
     return state;
   }
 
+  getQueryHints(query: string, result: any[]) {
+    return getQueryHints(query, result, this);
+  }
+
   loadRules() {
     this.metadataRequest('/api/v1/rules')
       .then(res => res.data || res.json())

+ 79 - 87
public/app/plugins/datasource/prometheus/query_hints.ts

@@ -1,100 +1,92 @@
 import _ from 'lodash';
 
-export function getQueryHints(series: any[], datasource?: any): any[] {
-  const hints = series.map((s, i) => {
-    const query: string = s.query;
-    const index: number = s.responseIndex;
-    if (query === undefined || index === undefined) {
-      return null;
-    }
+export function getQueryHints(query: string, series?: any[], datasource?: any): any[] {
+  const hints = [];
 
-    // ..._bucket metric needs a histogram_quantile()
-    const histogramMetric = query.trim().match(/^\w+_bucket$/);
-    if (histogramMetric) {
-      const label = 'Time series has buckets, you probably wanted a histogram.';
-      return {
-        index,
-        label,
-        fix: {
-          label: 'Fix by adding histogram_quantile().',
-          action: {
-            type: 'ADD_HISTOGRAM_QUANTILE',
-            query,
-            index,
-          },
+  // ..._bucket metric needs a histogram_quantile()
+  const histogramMetric = query.trim().match(/^\w+_bucket$/);
+  if (histogramMetric) {
+    const label = 'Time series has buckets, you probably wanted a histogram.';
+    hints.push({
+      type: 'HISTOGRAM_QUANTILE',
+      label,
+      fix: {
+        label: 'Fix by adding histogram_quantile().',
+        action: {
+          type: 'ADD_HISTOGRAM_QUANTILE',
+          query,
         },
-      };
-    }
+      },
+    });
+  }
 
-    // Check for monotony
-    const datapoints: number[][] = s.datapoints;
-    if (query.indexOf('rate(') === -1 && datapoints.length > 1) {
-      let increasing = false;
-      const nonNullData = datapoints.filter(dp => dp[0] !== null);
-      const monotonic = nonNullData.every((dp, index) => {
-        if (index === 0) {
-          return true;
-        }
-        increasing = increasing || dp[0] > nonNullData[index - 1][0];
-        // monotonic?
-        return dp[0] >= nonNullData[index - 1][0];
-      });
-      if (increasing && monotonic) {
-        const simpleMetric = query.trim().match(/^\w+$/);
-        let label = 'Time series is monotonously increasing.';
-        let fix;
-        if (simpleMetric) {
-          fix = {
-            label: 'Fix by adding rate().',
-            action: {
-              type: 'ADD_RATE',
-              query,
-              index,
-            },
-          };
-        } else {
-          label = `${label} Try applying a rate() function.`;
+  // Check for monotony on series (table results are being ignored here)
+  if (series && series.length > 0) {
+    series.forEach(s => {
+      const datapoints: number[][] = s.datapoints;
+      if (query.indexOf('rate(') === -1 && datapoints.length > 1) {
+        let increasing = false;
+        const nonNullData = datapoints.filter(dp => dp[0] !== null);
+        const monotonic = nonNullData.every((dp, index) => {
+          if (index === 0) {
+            return true;
+          }
+          increasing = increasing || dp[0] > nonNullData[index - 1][0];
+          // monotonic?
+          return dp[0] >= nonNullData[index - 1][0];
+        });
+        if (increasing && monotonic) {
+          const simpleMetric = query.trim().match(/^\w+$/);
+          let label = 'Time series is monotonously increasing.';
+          let fix;
+          if (simpleMetric) {
+            fix = {
+              label: 'Fix by adding rate().',
+              action: {
+                type: 'ADD_RATE',
+                query,
+              },
+            };
+          } else {
+            label = `${label} Try applying a rate() function.`;
+          }
+          hints.push({
+            type: 'APPLY_RATE',
+            label,
+            fix,
+          });
         }
-        return {
-          label,
-          index,
-          fix,
-        };
       }
-    }
+    });
+  }
 
-    // Check for recording rules expansion
-    if (datasource && datasource.ruleMappings) {
-      const mapping = datasource.ruleMappings;
-      const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => {
-        if (query.search(ruleName) > -1) {
-          return {
-            ...acc,
-            [ruleName]: mapping[ruleName],
-          };
-        }
-        return acc;
-      }, {});
-      if (_.size(mappingForQuery) > 0) {
-        const label = 'Query contains recording rules.';
+  // Check for recording rules expansion
+  if (datasource && datasource.ruleMappings) {
+    const mapping = datasource.ruleMappings;
+    const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => {
+      if (query.search(ruleName) > -1) {
         return {
-          label,
-          index,
-          fix: {
-            label: 'Expand rules',
-            action: {
-              type: 'EXPAND_RULES',
-              query,
-              index,
-              mapping: mappingForQuery,
-            },
-          },
+          ...acc,
+          [ruleName]: mapping[ruleName],
         };
       }
+      return acc;
+    }, {});
+    if (_.size(mappingForQuery) > 0) {
+      const label = 'Query contains recording rules.';
+      hints.push({
+        type: 'EXPAND_RULES',
+        label,
+        fix: {
+          label: 'Expand rules',
+          action: {
+            type: 'EXPAND_RULES',
+            query,
+            mapping: mappingForQuery,
+          },
+        },
+      });
     }
-
-    // No hint found
-    return null;
-  });
-  return hints;
+  }
+  return hints.length > 0 ? hints : null;
 }

+ 10 - 4
public/app/plugins/datasource/prometheus/result_transformer.ts

@@ -8,7 +8,14 @@ export class ResultTransformer {
     const prometheusResult = response.data.data.result;
 
     if (options.format === 'table') {
-      return [this.transformMetricDataToTable(prometheusResult, options.responseListLength, options.refId)];
+      return [
+        this.transformMetricDataToTable(
+          prometheusResult,
+          options.responseListLength,
+          options.refId,
+          options.valueWithRefId
+        ),
+      ];
     } else if (options.format === 'heatmap') {
       let seriesList = [];
       prometheusResult.sort(sortSeriesByLabel);
@@ -66,12 +73,11 @@ export class ResultTransformer {
     return {
       datapoints: dps,
       query: options.query,
-      responseIndex: options.responseIndex,
       target: metricLabel,
     };
   }
 
-  transformMetricDataToTable(md, resultCount: number, refId: string) {
+  transformMetricDataToTable(md, resultCount: number, refId: string, valueWithRefId?: boolean) {
     const table = new TableModel();
     let i, j;
     const metricLabels = {};
@@ -96,7 +102,7 @@ export class ResultTransformer {
       metricLabels[label] = labelIndex + 1;
       table.columns.push({ text: label, filterable: !label.startsWith('__') });
     });
-    const valueText = resultCount > 1 ? `Value #${refId}` : 'Value';
+    const valueText = resultCount > 1 || valueWithRefId ? `Value #${refId}` : 'Value';
     table.columns.push({ text: valueText });
 
     // Populate rows, set value to empty string when label not present.

+ 19 - 24
public/app/plugins/datasource/prometheus/specs/query_hints.test.ts

@@ -2,34 +2,31 @@ import { getQueryHints } from '../query_hints';
 
 describe('getQueryHints()', () => {
   it('returns no hints for no series', () => {
-    expect(getQueryHints([])).toEqual([]);
+    expect(getQueryHints('', [])).toEqual(null);
   });
 
   it('returns no hints for empty series', () => {
-    expect(getQueryHints([{ datapoints: [], query: '' }])).toEqual([null]);
+    expect(getQueryHints('', [{ datapoints: [] }])).toEqual(null);
   });
 
   it('returns no hint for a monotonously decreasing series', () => {
-    const series = [{ datapoints: [[23, 1000], [22, 1001]], query: 'metric', responseIndex: 0 }];
-    const hints = getQueryHints(series);
-    expect(hints).toEqual([null]);
+    const series = [{ datapoints: [[23, 1000], [22, 1001]] }];
+    const hints = getQueryHints('metric', series);
+    expect(hints).toEqual(null);
   });
 
   it('returns no hint for a flat series', () => {
-    const series = [
-      { datapoints: [[null, 1000], [23, 1001], [null, 1002], [23, 1003]], query: 'metric', responseIndex: 0 },
-    ];
-    const hints = getQueryHints(series);
-    expect(hints).toEqual([null]);
+    const series = [{ datapoints: [[null, 1000], [23, 1001], [null, 1002], [23, 1003]] }];
+    const hints = getQueryHints('metric', series);
+    expect(hints).toEqual(null);
   });
 
   it('returns a rate hint for a monotonously increasing series', () => {
-    const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'metric', responseIndex: 0 }];
-    const hints = getQueryHints(series);
+    const series = [{ datapoints: [[23, 1000], [24, 1001]] }];
+    const hints = getQueryHints('metric', series);
     expect(hints.length).toBe(1);
     expect(hints[0]).toMatchObject({
       label: 'Time series is monotonously increasing.',
-      index: 0,
       fix: {
         action: {
           type: 'ADD_RATE',
@@ -40,26 +37,25 @@ describe('getQueryHints()', () => {
   });
 
   it('returns no rate hint for a monotonously increasing series that already has a rate', () => {
-    const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'rate(metric[1m])', responseIndex: 0 }];
-    const hints = getQueryHints(series);
-    expect(hints).toEqual([null]);
+    const series = [{ datapoints: [[23, 1000], [24, 1001]] }];
+    const hints = getQueryHints('rate(metric[1m])', series);
+    expect(hints).toEqual(null);
   });
 
   it('returns a rate hint w/o action for a complex monotonously increasing series', () => {
-    const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'sum(metric)', responseIndex: 0 }];
-    const hints = getQueryHints(series);
+    const series = [{ datapoints: [[23, 1000], [24, 1001]] }];
+    const hints = getQueryHints('sum(metric)', series);
     expect(hints.length).toBe(1);
     expect(hints[0].label).toContain('rate()');
     expect(hints[0].fix).toBeUndefined();
   });
 
   it('returns a rate hint for a monotonously increasing series with missing data', () => {
-    const series = [{ datapoints: [[23, 1000], [null, 1001], [24, 1002]], query: 'metric', responseIndex: 0 }];
-    const hints = getQueryHints(series);
+    const series = [{ datapoints: [[23, 1000], [null, 1001], [24, 1002]] }];
+    const hints = getQueryHints('metric', series);
     expect(hints.length).toBe(1);
     expect(hints[0]).toMatchObject({
       label: 'Time series is monotonously increasing.',
-      index: 0,
       fix: {
         action: {
           type: 'ADD_RATE',
@@ -70,12 +66,11 @@ describe('getQueryHints()', () => {
   });
 
   it('returns a histogram hint for a bucket series', () => {
-    const series = [{ datapoints: [[23, 1000]], query: 'metric_bucket', responseIndex: 0 }];
-    const hints = getQueryHints(series);
+    const series = [{ datapoints: [[23, 1000]] }];
+    const hints = getQueryHints('metric_bucket', series);
     expect(hints.length).toBe(1);
     expect(hints[0]).toMatchObject({
       label: 'Time series has buckets, you probably wanted a histogram.',
-      index: 0,
       fix: {
         action: {
           type: 'ADD_HISTOGRAM_QUANTILE',

+ 23 - 12
public/app/types/explore.ts

@@ -3,6 +3,11 @@ interface ExploreDatasource {
   label: string;
 }
 
+export interface HistoryItem {
+  ts: number;
+  query: string;
+}
+
 export interface Range {
   from: string;
   to: string;
@@ -13,6 +18,19 @@ export interface Query {
   key?: string;
 }
 
+export interface QueryTransaction {
+  id: string;
+  done: boolean;
+  error?: string;
+  hints?: any[];
+  latency: number;
+  options: any;
+  query: string;
+  result?: any; // Table model / Timeseries[] / Logs
+  resultType: ResultType;
+  rowIndex: number;
+}
+
 export interface TextMatch {
   text: string;
   start: number;
@@ -27,34 +45,25 @@ export interface ExploreState {
   datasourceMissing: boolean;
   datasourceName?: string;
   exploreDatasources: ExploreDatasource[];
-  graphResult: any;
-  history: any[];
-  latency: number;
-  loading: any;
-  logsResult: any;
+  graphRange: Range;
+  history: HistoryItem[];
   /**
    * Initial rows of queries to push down the tree.
    * Modifications do not end up here, but in `this.queryExpressions`.
    * The only way to reset a query is to change its `key`.
    */
   queries: Query[];
-  /**
-   * Errors caused by the running the query row.
-   */
-  queryErrors: any[];
   /**
    * Hints gathered for the query row.
    */
-  queryHints: any[];
+  queryTransactions: QueryTransaction[];
   range: Range;
-  requestOptions: any;
   showingGraph: boolean;
   showingLogs: boolean;
   showingTable: boolean;
   supportsGraph: boolean | null;
   supportsLogs: boolean | null;
   supportsTable: boolean | null;
-  tableResult: any;
 }
 
 export interface ExploreUrlState {
@@ -62,3 +71,5 @@ export interface ExploreUrlState {
   queries: Query[];
   range: Range;
 }
+
+export type ResultType = 'Graph' | 'Logs' | 'Table';

+ 75 - 1
public/sass/pages/_explore.scss

@@ -74,7 +74,7 @@
     }
   }
 
-  .elapsed-time {
+  .navbar .elapsed-time {
     position: absolute;
     left: 0;
     right: 0;
@@ -87,6 +87,37 @@
     flex-wrap: wrap;
   }
 
+  .explore-graph__loader {
+    height: 2px;
+    position: relative;
+    overflow: hidden;
+    background: $text-color-faint;
+    margin: $panel-margin / 2;
+  }
+
+  .explore-graph__loader:after {
+    content: ' ';
+    display: block;
+    width: 25%;
+    top: 0;
+    top: -50%;
+    height: 250%;
+    position: absolute;
+    animation: loader 2s cubic-bezier(0.17, 0.67, 0.83, 0.67);
+    animation-iteration-count: 100;
+    z-index: 2;
+    background: $blue;
+  }
+
+  @keyframes loader {
+    from {
+      left: -25%;
+    }
+    to {
+      left: 100%;
+    }
+  }
+
   .datasource-picker {
     min-width: 200px;
   }
@@ -119,6 +150,7 @@
 
 .query-row {
   display: flex;
+  position: relative;
 
   & + & {
     margin-top: 0.5rem;
@@ -129,11 +161,53 @@
   white-space: nowrap;
 }
 
+.query-row-status {
+  position: absolute;
+  top: 0;
+  right: 90px;
+  z-index: 1024;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  height: 34px;
+}
+
 .query-row-field {
   margin-right: 3px;
   width: 100%;
 }
 
+.query-transactions {
+  display: table;
+}
+
+.query-transaction {
+  display: table-row;
+  color: $text-color-faint;
+  line-height: 1.44;
+}
+
+.query-transaction--loading {
+  animation: query-loading-color-change 1s alternate 100;
+}
+
+@keyframes query-loading-color-change {
+  from {
+    color: $text-color-faint;
+  }
+  to {
+    color: $blue;
+  }
+}
+
+.query-transaction__type,
+.query-transaction__duration {
+  display: table-cell;
+  font-size: $font-size-xs;
+  text-align: right;
+  padding-right: 0.25em;
+}
+
 .explore {
   .logs {
     .logs-entries {