Browse Source

Explore: query transactions

Existing querying was grouped together before handed over to the
datasource. This slowed down result display to however long the slowest
query took.

- create one query transaction per result viewer (graph, table, etc.)
  and query row
- track latencies for each transaction
- show results as soon as they are being received
- loading indicator on graph and query button to indicate that queries
  are still running and that results are incomplete
- properly discard transactions when removing or changing queries
David Kaltschmidt 7 years ago
parent
commit
2e02a8c855

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

@@ -8,23 +8,18 @@ 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', () => {

+ 292 - 137
public/app/features/explore/Explore.tsx

@@ -1,8 +1,9 @@
 import React from 'react';
 import { hot } from 'react-hot-loader';
 import Select from 'react-select';
+import _ from 'lodash';
 
-import { ExploreState, ExploreUrlState, Query } from 'app/types/explore';
+import { ExploreState, ExploreUrlState, HistoryItem, Query, QueryTransaction, Range } 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 +16,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';
@@ -53,6 +53,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 +102,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 +110,18 @@ 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 +215,30 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
   };
 
   onAddQueryRow = index => {
-    const { queries } = this.state;
+    const { queries, queryTransactions } = this.state;
+
+    // Local cache
     this.queryExpressions[index + 1] = '';
+
+    // Add row by generating new react key
     const nextQueries = [
       ...queries.slice(0, index + 1),
       { query: '', key: generateQueryKey() },
       ...queries.slice(index + 1),
     ];
-    this.setState({ queries: nextQueries });
+
+    // 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;
+    });
+
+    this.setState({ queries: nextQueries, queryTransactions: nextQueryTransactions });
   };
 
   onChangeDatasource = async option => {
@@ -215,12 +246,8 @@ 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,9 +258,9 @@ 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;
+      // Replace query row
+      const { queries, queryTransactions } = this.state;
       const nextQuery: Query = {
         key: generateQueryKey(index),
         query: value,
@@ -241,11 +268,14 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
       const nextQueries = [...queries];
       nextQueries[index] = nextQuery;
 
+      // Discard ongoing transaction related to row query
+      const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
+
       this.setState(
         {
-          queryErrors: [],
-          queryHints: [],
           queries: nextQueries,
+          queryHints: [],
+          queryTransactions: nextQueryTransactions,
         },
         this.onSubmit
       );
@@ -264,13 +294,9 @@ 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
     );
@@ -308,15 +334,18 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
   };
 
   onModifyQueries = (action: object, index?: number) => {
-    const { datasource, queries } = this.state;
+    const { datasource, queries, queryTransactions } = this.state;
     if (datasource && datasource.modifyQuery) {
       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 = [
@@ -327,20 +356,41 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
           },
           ...queries.slice(index + 1),
         ];
+        // Discard transactions related to row query
+        nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
       }
       this.queryExpressions = nextQueries.map(q => q.query);
-      this.setState({ queries: nextQueries }, () => this.onSubmit());
+      this.setState(
+        {
+          queries: nextQueries,
+          queryTransactions: nextQueryTransactions,
+        },
+        () => this.onSubmit()
+      );
     }
   };
 
   onRemoveQueryRow = index => {
-    const { queries } = this.state;
+    const { queries, queryTransactions } = this.state;
     if (queries.length <= 1) {
       return;
     }
+    // Remove from local cache
+    this.queryExpressions = [...this.queryExpressions.slice(0, index), ...this.queryExpressions.slice(index + 1)];
+
+    // Remove row from react state
     const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)];
-    this.queryExpressions = nextQueries.map(q => q.query);
-    this.setState({ queries: nextQueries }, () => this.onSubmit());
+
+    // Discard transactions related to row query
+    const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
+
+    this.setState(
+      {
+        queries: nextQueries,
+        queryTransactions: nextQueryTransactions,
+      },
+      () => this.onSubmit()
+    );
   };
 
   onSubmit = () => {
@@ -349,7 +399,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 +407,7 @@ 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 +415,215 @@ 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,
+    };
+  }
+
+  startQueryTransaction(query: string, rowIndex: number, resultType: string, 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 {
+        queryHints: [],
+        queryTransactions: nextQueryTransactions,
+      };
+    });
+
+    return transaction;
   }
 
-  async runGraphQuery() {
+  completeQueryTransaction(
+    transactionId: string,
+    result: any,
+    latency: number,
+    hints: any[],
+    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
+      if (!queryTransactions.find(qt => qt.id === transactionId)) {
+        return null;
+      }
+
+      // Mark transactions as complete
+      const nextQueryTransactions = queryTransactions.map(qt => {
+        if (qt.id === transactionId) {
+          return {
+            ...qt,
+            latency,
+            result,
+            done: true,
+          };
+        }
+        return qt;
+      });
+
+      const nextHistory = updateHistory(history, datasourceId, queries);
+
+      return {
+        history: nextHistory,
+        queryHints: hints,
+        queryTransactions: nextQueryTransactions,
+      };
+    });
+  }
+
+  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,
+          hinting: true,
+        });
+        try {
+          const now = Date.now();
+          const res = await datasource.query(transaction.options);
+          const latency = Date.now() - now;
+          const results = makeTimeSeriesList(res.data, transaction.options);
+          const queryHints = res.hints ? makeHints(res.hints) : [];
+          this.completeQueryTransaction(transaction.id, results, latency, queryHints, 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);
+        }
+      }
+    });
   }
 
   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 });
+        try {
+          const now = Date.now();
+          const res = await datasource.query(transaction.options);
+          const latency = Date.now() - now;
+          const results = mergeTablesIntoModel(new TableModel(), ...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);
+        }
+      }
     });
-    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);
+        }
+      }
     });
-
-    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 => {
@@ -502,23 +652,18 @@ 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 +672,17 @@ 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 = queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done).map(qt => qt.result)[0];
+    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}>
@@ -539,12 +695,12 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
               </a>
             </div>
           ) : (
-            <div className="navbar-buttons explore-first-button">
-              <button className="btn navbar-button" onClick={this.onClickCloseSplit}>
-                Close Split
+              <div className="navbar-buttons explore-first-button">
+                <button className="btn navbar-button" onClick={this.onClickCloseSplit}>
+                  Close Split
               </button>
-            </div>
-          )}
+              </div>
+            )}
           {!datasourceMissing ? (
             <div className="navbar-buttons">
               <Select
@@ -584,9 +740,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,7 +761,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
             <QueryRows
               history={history}
               queries={queries}
-              queryErrors={queryErrors}
               queryHints={queryHints}
               request={this.request}
               onAddQueryRow={this.onAddQueryRow}
@@ -614,6 +769,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 +791,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>
+      </>
     );
   }
 }

+ 9 - 3
public/app/features/explore/QueryRows.tsx

@@ -2,6 +2,7 @@ import React, { PureComponent } from 'react';
 
 // TODO make this datasource-plugin-dependent
 import QueryField from './PromQueryField';
+import QueryTransactions from './QueryTransactions';
 
 class QueryRow extends PureComponent<any, {}> {
   onChangeQuery = (value, override?: boolean) => {
@@ -44,9 +45,14 @@ class QueryRow extends PureComponent<any, {}> {
   };
 
   render() {
-    const { history, query, queryError, queryHint, request, supportsLogs } = this.props;
+    const { history, query, queryHint, request, supportsLogs, transactions } = this.props;
+    const transactionWithError = transactions.find(t => t.error);
+    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}
@@ -78,7 +84,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,7 +92,7 @@ export default class QueryRows extends PureComponent<any, {}> {
             key={q.key}
             index={index}
             query={q.query}
-            queryError={queryErrors[index]}
+            transactions={transactions.filter(t => t.rowIndex === index)}
             queryHint={queryHints[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>
 `;

+ 20 - 11
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,18 @@ export interface Query {
   key?: string;
 }
 
+export interface QueryTransaction {
+  id: string;
+  done: boolean;
+  error?: string;
+  latency: number;
+  options: any;
+  query: string;
+  result?: any; // Table / Timeseries / Logs
+  resultType: string;
+  rowIndex: number;
+}
+
 export interface TextMatch {
   text: string;
   start: number;
@@ -27,34 +44,26 @@ 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 {

+ 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: $table-border;
+    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 {