Browse Source

Merge pull request #14821 from grafana/davkal/explore-redux

Explore: Redux migration
David 7 năm trước cách đây
mục cha
commit
25e6d075ab

+ 22 - 50
public/app/core/utils/explore.test.ts

@@ -6,26 +6,13 @@ import {
   clearHistory,
   clearHistory,
   hasNonEmptyQuery,
   hasNonEmptyQuery,
 } from './explore';
 } from './explore';
-import { ExploreState } from 'app/types/explore';
+import { ExploreUrlState } from 'app/types/explore';
 import store from 'app/core/store';
 import store from 'app/core/store';
 
 
-const DEFAULT_EXPLORE_STATE: ExploreState = {
+const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
   datasource: null,
   datasource: null,
-  datasourceError: null,
-  datasourceLoading: null,
-  datasourceMissing: false,
-  exploreDatasources: [],
-  graphInterval: 1000,
-  history: [],
-  initialQueries: [],
-  queryTransactions: [],
+  queries: [],
   range: DEFAULT_RANGE,
   range: DEFAULT_RANGE,
-  showingGraph: true,
-  showingLogs: true,
-  showingTable: true,
-  supportsGraph: null,
-  supportsLogs: null,
-  supportsTable: null,
 };
 };
 
 
 describe('state functions', () => {
 describe('state functions', () => {
@@ -68,21 +55,19 @@ describe('state functions', () => {
     it('returns url parameter value for a state object', () => {
     it('returns url parameter value for a state object', () => {
       const state = {
       const state = {
         ...DEFAULT_EXPLORE_STATE,
         ...DEFAULT_EXPLORE_STATE,
-        initialDatasource: 'foo',
-        range: {
-          from: 'now-5h',
-          to: 'now',
-        },
-        initialQueries: [
+        datasource: 'foo',
+        queries: [
           {
           {
-            refId: '1',
             expr: 'metric{test="a/b"}',
             expr: 'metric{test="a/b"}',
           },
           },
           {
           {
-            refId: '2',
             expr: 'super{foo="x/z"}',
             expr: 'super{foo="x/z"}',
           },
           },
         ],
         ],
+        range: {
+          from: 'now-5h',
+          to: 'now',
+        },
       };
       };
       expect(serializeStateToUrlParam(state)).toBe(
       expect(serializeStateToUrlParam(state)).toBe(
         '{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' +
         '{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' +
@@ -93,21 +78,19 @@ describe('state functions', () => {
     it('returns url parameter value for a state object', () => {
     it('returns url parameter value for a state object', () => {
       const state = {
       const state = {
         ...DEFAULT_EXPLORE_STATE,
         ...DEFAULT_EXPLORE_STATE,
-        initialDatasource: 'foo',
-        range: {
-          from: 'now-5h',
-          to: 'now',
-        },
-        initialQueries: [
+        datasource: 'foo',
+        queries: [
           {
           {
-            refId: '1',
             expr: 'metric{test="a/b"}',
             expr: 'metric{test="a/b"}',
           },
           },
           {
           {
-            refId: '2',
             expr: 'super{foo="x/z"}',
             expr: 'super{foo="x/z"}',
           },
           },
         ],
         ],
+        range: {
+          from: 'now-5h',
+          to: 'now',
+        },
       };
       };
       expect(serializeStateToUrlParam(state, true)).toBe(
       expect(serializeStateToUrlParam(state, true)).toBe(
         '["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"}]'
         '["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"}]'
@@ -119,35 +102,24 @@ describe('state functions', () => {
     it('can parse the serialized state into the original state', () => {
     it('can parse the serialized state into the original state', () => {
       const state = {
       const state = {
         ...DEFAULT_EXPLORE_STATE,
         ...DEFAULT_EXPLORE_STATE,
-        initialDatasource: 'foo',
-        range: {
-          from: 'now - 5h',
-          to: 'now',
-        },
-        initialQueries: [
+        datasource: 'foo',
+        queries: [
           {
           {
-            refId: '1',
             expr: 'metric{test="a/b"}',
             expr: 'metric{test="a/b"}',
           },
           },
           {
           {
-            refId: '2',
             expr: 'super{foo="x/z"}',
             expr: 'super{foo="x/z"}',
           },
           },
         ],
         ],
+        range: {
+          from: 'now - 5h',
+          to: 'now',
+        },
       };
       };
       const serialized = serializeStateToUrlParam(state);
       const serialized = serializeStateToUrlParam(state);
       const parsed = parseUrlState(serialized);
       const parsed = parseUrlState(serialized);
 
 
-      // Account for datasource vs datasourceName
-      const { datasource, queries, ...rest } = parsed;
-      const resultState = {
-        ...rest,
-        datasource: DEFAULT_EXPLORE_STATE.datasource,
-        initialDatasource: datasource,
-        initialQueries: queries,
-      };
-
-      expect(state).toMatchObject(resultState);
+      expect(state).toMatchObject(parsed);
     });
     });
   });
   });
 });
 });

+ 85 - 22
public/app/core/utils/explore.ts

@@ -1,6 +1,7 @@
 import _ from 'lodash';
 import _ from 'lodash';
-import { colors } from '@grafana/ui';
+import { colors, RawTimeRange, IntervalValues } from '@grafana/ui';
 
 
+import * as dateMath from 'app/core/utils/datemath';
 import { renderUrl } from 'app/core/utils/url';
 import { renderUrl } from 'app/core/utils/url';
 import kbn from 'app/core/utils/kbn';
 import kbn from 'app/core/utils/kbn';
 import store from 'app/core/store';
 import store from 'app/core/store';
@@ -8,9 +9,15 @@ import { parse as parseDate } from 'app/core/utils/datemath';
 
 
 import TimeSeries from 'app/core/time_series2';
 import TimeSeries from 'app/core/time_series2';
 import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
 import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
-import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore';
-import { DataQuery, DataSourceApi } from 'app/types/series';
-import { RawTimeRange, IntervalValues } from '@grafana/ui';
+import {
+  ExploreUrlState,
+  HistoryItem,
+  QueryTransaction,
+  ResultType,
+  QueryIntervals,
+  QueryOptions,
+} from 'app/types/explore';
+import { DataQuery } from 'app/types/series';
 
 
 export const DEFAULT_RANGE = {
 export const DEFAULT_RANGE = {
   from: 'now-6h',
   from: 'now-6h',
@@ -19,6 +26,8 @@ export const DEFAULT_RANGE = {
 
 
 const MAX_HISTORY_ITEMS = 100;
 const MAX_HISTORY_ITEMS = 100;
 
 
+export const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
+
 /**
 /**
  * Returns an Explore-URL that contains a panel's queries and the dashboard time range.
  * Returns an Explore-URL that contains a panel's queries and the dashboard time range.
  *
  *
@@ -77,7 +86,63 @@ export async function getExploreUrl(
   return url;
   return url;
 }
 }
 
 
-const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
+export function buildQueryTransaction(
+  query: DataQuery,
+  rowIndex: number,
+  resultType: ResultType,
+  queryOptions: QueryOptions,
+  range: RawTimeRange,
+  queryIntervals: QueryIntervals,
+  scanning: boolean
+): QueryTransaction {
+  const { interval, intervalMs } = queryIntervals;
+
+  const configuredQueries = [
+    {
+      ...query,
+      ...queryOptions,
+    },
+  ];
+
+  // Clone range for query request
+  // const queryRange: RawTimeRange = { ...range };
+  // const { from, to, raw } = this.timeSrv.timeRange();
+  // Most datasource is using `panelId + query.refId` for cancellation logic.
+  // Using `format` here because it relates to the view panel that the request is for.
+  // However, some datasources don't use `panelId + query.refId`, but only `panelId`.
+  // Therefore panel id has to be unique.
+  const panelId = `${queryOptions.format}-${query.key}`;
+
+  const options = {
+    interval,
+    intervalMs,
+    panelId,
+    targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key.
+    range: {
+      from: dateMath.parse(range.from, false),
+      to: dateMath.parse(range.to, true),
+      raw: range,
+    },
+    rangeRaw: range,
+    scopedVars: {
+      __interval: { text: interval, value: interval },
+      __interval_ms: { text: intervalMs, value: intervalMs },
+    },
+  };
+
+  return {
+    options,
+    query,
+    resultType,
+    rowIndex,
+    scanning,
+    id: generateKey(), // reusing for unique ID
+    done: false,
+    latency: 0,
+  };
+}
+
+export const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
 
 
 export function parseUrlState(initial: string | undefined): ExploreUrlState {
 export function parseUrlState(initial: string | undefined): ExploreUrlState {
   if (initial) {
   if (initial) {
@@ -103,12 +168,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
   return { datasource: null, queries: [], range: DEFAULT_RANGE };
   return { datasource: null, queries: [], range: DEFAULT_RANGE };
 }
 }
 
 
-export function serializeStateToUrlParam(state: ExploreState, compact?: boolean): string {
-  const urlState: ExploreUrlState = {
-    datasource: state.initialDatasource,
-    queries: state.initialQueries.map(clearQueryKeys),
-    range: state.range,
-  };
+export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string {
   if (compact) {
   if (compact) {
     return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]);
     return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]);
   }
   }
@@ -123,7 +183,7 @@ export function generateRefId(index = 0): string {
   return `${index + 1}`;
   return `${index + 1}`;
 }
 }
 
 
-export function generateQueryKeys(index = 0): { refId: string; key: string } {
+export function generateEmptyQuery(index = 0): { refId: string; key: string } {
   return { refId: generateRefId(index), key: generateKey(index) };
   return { refId: generateRefId(index), key: generateKey(index) };
 }
 }
 
 
@@ -132,20 +192,23 @@ export function generateQueryKeys(index = 0): { refId: string; key: string } {
  */
  */
 export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
 export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
   if (queries && typeof queries === 'object' && queries.length > 0) {
   if (queries && typeof queries === 'object' && queries.length > 0) {
-    return queries.map((query, i) => ({ ...query, ...generateQueryKeys(i) }));
+    return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(i) }));
   }
   }
-  return [{ ...generateQueryKeys() }];
+  return [{ ...generateEmptyQuery() }];
 }
 }
 
 
 /**
 /**
  * A target is non-empty when it has keys (with non-empty values) other than refId and key.
  * A target is non-empty when it has keys (with non-empty values) other than refId and key.
  */
  */
 export function hasNonEmptyQuery(queries: DataQuery[]): boolean {
 export function hasNonEmptyQuery(queries: DataQuery[]): boolean {
-  return queries.some(
-    query =>
-      Object.keys(query)
-        .map(k => query[k])
-        .filter(v => v).length > 2
+  return (
+    queries &&
+    queries.some(
+      query =>
+        Object.keys(query)
+          .map(k => query[k])
+          .filter(v => v).length > 2
+    )
   );
   );
 }
 }
 
 
@@ -180,8 +243,8 @@ export function calculateResultsFromQueryTransactions(
   };
   };
 }
 }
 
 
-export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, resolution: number): IntervalValues {
-  if (!datasource || !resolution) {
+export function getIntervals(range: RawTimeRange, lowLimit: string, resolution: number): IntervalValues {
+  if (!resolution) {
     return { interval: '1s', intervalMs: 1000 };
     return { interval: '1s', intervalMs: 1000 };
   }
   }
 
 
@@ -190,7 +253,7 @@ export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, res
     to: parseDate(range.to, true),
     to: parseDate(range.to, true),
   };
   };
 
 
-  return kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
+  return kbn.calculateInterval(absoluteRange, resolution, lowLimit);
 }
 }
 
 
 export function makeTimeSeriesList(dataList) {
 export function makeTimeSeriesList(dataList) {

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 113 - 788
public/app/features/explore/Explore.tsx


+ 61 - 0
public/app/features/explore/GraphContainer.tsx

@@ -0,0 +1,61 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import { RawTimeRange, TimeRange } from '@grafana/ui';
+
+import { ExploreId, ExploreItemState } from 'app/types/explore';
+import { StoreState } from 'app/types';
+
+import { toggleGraph } from './state/actions';
+import Graph from './Graph';
+import Panel from './Panel';
+
+interface GraphContainerProps {
+  onChangeTime: (range: TimeRange) => void;
+  exploreId: ExploreId;
+  graphResult?: any[];
+  loading: boolean;
+  range: RawTimeRange;
+  showingGraph: boolean;
+  showingTable: boolean;
+  split: boolean;
+  toggleGraph: typeof toggleGraph;
+}
+
+export class GraphContainer extends PureComponent<GraphContainerProps> {
+  onClickGraphButton = () => {
+    this.props.toggleGraph(this.props.exploreId);
+  };
+
+  render() {
+    const { exploreId, graphResult, loading, onChangeTime, showingGraph, showingTable, range, split } = this.props;
+    const graphHeight = showingGraph && showingTable ? '200px' : '400px';
+    return (
+      <Panel label="Graph" isOpen={showingGraph} loading={loading} onToggle={this.onClickGraphButton}>
+        <Graph
+          data={graphResult}
+          height={graphHeight}
+          id={`explore-graph-${exploreId}`}
+          onChangeTime={onChangeTime}
+          range={range}
+          split={split}
+        />
+      </Panel>
+    );
+  }
+}
+
+function mapStateToProps(state: StoreState, { exploreId }) {
+  const explore = state.explore;
+  const { split } = explore;
+  const item: ExploreItemState = explore[exploreId];
+  const { graphResult, queryTransactions, range, showingGraph, showingTable } = item;
+  const loading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done);
+  return { graphResult, loading, range, showingGraph, showingTable, split };
+}
+
+const mapDispatchToProps = {
+  toggleGraph,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(GraphContainer));

+ 3 - 3
public/app/features/explore/Logs.tsx

@@ -241,9 +241,9 @@ function renderMetaItem(value: any, kind: LogsMetaKind) {
 
 
 interface LogsProps {
 interface LogsProps {
   data: LogsModel;
   data: LogsModel;
+  exploreId: string;
   highlighterExpressions: string[];
   highlighterExpressions: string[];
   loading: boolean;
   loading: boolean;
-  position: string;
   range?: RawTimeRange;
   range?: RawTimeRange;
   scanning?: boolean;
   scanning?: boolean;
   scanRange?: RawTimeRange;
   scanRange?: RawTimeRange;
@@ -348,10 +348,10 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
   render() {
   render() {
     const {
     const {
       data,
       data,
+      exploreId,
       highlighterExpressions,
       highlighterExpressions,
       loading = false,
       loading = false,
       onClickLabel,
       onClickLabel,
-      position,
       range,
       range,
       scanning,
       scanning,
       scanRange,
       scanRange,
@@ -400,7 +400,7 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
             data={data.series}
             data={data.series}
             height="100px"
             height="100px"
             range={range}
             range={range}
-            id={`explore-logs-graph-${position}`}
+            id={`explore-logs-graph-${exploreId}`}
             onChangeTime={this.props.onChangeTime}
             onChangeTime={this.props.onChangeTime}
             onToggleSeries={this.onToggleLogLevel}
             onToggleSeries={this.onToggleLogLevel}
             userOptions={graphOptions}
             userOptions={graphOptions}

+ 91 - 0
public/app/features/explore/LogsContainer.tsx

@@ -0,0 +1,91 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import { RawTimeRange, TimeRange } from '@grafana/ui';
+
+import { ExploreId, ExploreItemState } from 'app/types/explore';
+import { LogsModel } from 'app/core/logs_model';
+import { StoreState } from 'app/types';
+
+import { toggleLogs } from './state/actions';
+import Logs from './Logs';
+import Panel from './Panel';
+
+interface LogsContainerProps {
+  exploreId: ExploreId;
+  loading: boolean;
+  logsHighlighterExpressions?: string[];
+  logsResult?: LogsModel;
+  onChangeTime: (range: TimeRange) => void;
+  onClickLabel: (key: string, value: string) => void;
+  onStartScanning: () => void;
+  onStopScanning: () => void;
+  range: RawTimeRange;
+  scanning?: boolean;
+  scanRange?: RawTimeRange;
+  showingLogs: boolean;
+  toggleLogs: typeof toggleLogs;
+}
+
+export class LogsContainer extends PureComponent<LogsContainerProps> {
+  onClickLogsButton = () => {
+    this.props.toggleLogs(this.props.exploreId);
+  };
+
+  render() {
+    const {
+      exploreId,
+      loading,
+      logsHighlighterExpressions,
+      logsResult,
+      onChangeTime,
+      onClickLabel,
+      onStartScanning,
+      onStopScanning,
+      range,
+      showingLogs,
+      scanning,
+      scanRange,
+    } = this.props;
+    return (
+      <Panel label="Logs" loading={loading} isOpen={showingLogs} onToggle={this.onClickLogsButton}>
+        <Logs
+          data={logsResult}
+          exploreId={exploreId}
+          key={logsResult.id}
+          highlighterExpressions={logsHighlighterExpressions}
+          loading={loading}
+          onChangeTime={onChangeTime}
+          onClickLabel={onClickLabel}
+          onStartScanning={onStartScanning}
+          onStopScanning={onStopScanning}
+          range={range}
+          scanning={scanning}
+          scanRange={scanRange}
+        />
+      </Panel>
+    );
+  }
+}
+
+function mapStateToProps(state: StoreState, { exploreId }) {
+  const explore = state.explore;
+  const item: ExploreItemState = explore[exploreId];
+  const { logsHighlighterExpressions, logsResult, queryTransactions, scanning, scanRange, showingLogs, range } = item;
+  const loading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done);
+  return {
+    loading,
+    logsHighlighterExpressions,
+    logsResult,
+    scanning,
+    scanRange,
+    showingLogs,
+    range,
+  };
+}
+
+const mapDispatchToProps = {
+  toggleLogs,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(LogsContainer));

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

@@ -48,7 +48,7 @@ export default class QueryEditor extends PureComponent<QueryEditorProps, any> {
           getNextQueryLetter: x => '',
           getNextQueryLetter: x => '',
         },
         },
         hideEditorRowActions: true,
         hideEditorRowActions: true,
-        ...getIntervals(range, datasource, null), // Possible to get resolution?
+        ...getIntervals(range, (datasource || {}).interval, null), // Possible to get resolution?
       },
       },
     };
     };
 
 

+ 163 - 0
public/app/features/explore/QueryRow.tsx

@@ -0,0 +1,163 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import { RawTimeRange } from '@grafana/ui';
+import _ from 'lodash';
+
+import { QueryTransaction, HistoryItem, QueryHint, ExploreItemState, ExploreId } from 'app/types/explore';
+import { Emitter } from 'app/core/utils/emitter';
+import { DataQuery, StoreState } from 'app/types';
+
+// import DefaultQueryField from './QueryField';
+import QueryEditor from './QueryEditor';
+import QueryTransactionStatus from './QueryTransactionStatus';
+import {
+  addQueryRow,
+  changeQuery,
+  highlightLogsExpression,
+  modifyQueries,
+  removeQueryRow,
+  runQueries,
+} from './state/actions';
+
+function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
+  const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
+  if (transaction) {
+    return transaction.hints[0];
+  }
+  return undefined;
+}
+
+interface QueryRowProps {
+  addQueryRow: typeof addQueryRow;
+  changeQuery: typeof changeQuery;
+  className?: string;
+  exploreId: ExploreId;
+  datasourceInstance: any;
+  highlightLogsExpression: typeof highlightLogsExpression;
+  history: HistoryItem[];
+  index: number;
+  initialQuery: DataQuery;
+  modifyQueries: typeof modifyQueries;
+  queryTransactions: QueryTransaction[];
+  exploreEvents: Emitter;
+  range: RawTimeRange;
+  removeQueryRow: typeof removeQueryRow;
+  runQueries: typeof runQueries;
+}
+
+export class QueryRow extends PureComponent<QueryRowProps> {
+  onExecuteQuery = () => {
+    const { exploreId } = this.props;
+    this.props.runQueries(exploreId);
+  };
+
+  onChangeQuery = (query: DataQuery, override?: boolean) => {
+    const { datasourceInstance, exploreId, index } = this.props;
+    this.props.changeQuery(exploreId, query, index, override);
+    if (query && !override && datasourceInstance.getHighlighterExpression && index === 0) {
+      // Live preview of log search matches. Only use on first row for now
+      this.updateLogsHighlights(query);
+    }
+  };
+
+  onClickAddButton = () => {
+    const { exploreId, index } = this.props;
+    this.props.addQueryRow(exploreId, index);
+  };
+
+  onClickClearButton = () => {
+    this.onChangeQuery(null, true);
+  };
+
+  onClickHintFix = action => {
+    const { datasourceInstance, exploreId, index } = this.props;
+    if (datasourceInstance && datasourceInstance.modifyQuery) {
+      const modifier = (queries: DataQuery, action: any) => datasourceInstance.modifyQuery(queries, action);
+      this.props.modifyQueries(exploreId, action, index, modifier);
+    }
+  };
+
+  onClickRemoveButton = () => {
+    const { exploreId, index } = this.props;
+    this.props.removeQueryRow(exploreId, index);
+  };
+
+  updateLogsHighlights = _.debounce((value: DataQuery) => {
+    const { datasourceInstance } = this.props;
+    if (datasourceInstance.getHighlighterExpression) {
+      const expressions = [datasourceInstance.getHighlighterExpression(value)];
+      this.props.highlightLogsExpression(this.props.exploreId, expressions);
+    }
+  }, 500);
+
+  render() {
+    const { datasourceInstance, history, index, initialQuery, queryTransactions, exploreEvents, range } = this.props;
+    const transactions = queryTransactions.filter(t => t.rowIndex === index);
+    const transactionWithError = transactions.find(t => t.error !== undefined);
+    const hint = getFirstHintFromTransactions(transactions);
+    const queryError = transactionWithError ? transactionWithError.error : null;
+    const QueryField = datasourceInstance.pluginExports.ExploreQueryField;
+    return (
+      <div className="query-row">
+        <div className="query-row-status">
+          <QueryTransactionStatus transactions={transactions} />
+        </div>
+        <div className="query-row-field">
+          {QueryField ? (
+            <QueryField
+              datasource={datasourceInstance}
+              error={queryError}
+              hint={hint}
+              initialQuery={initialQuery}
+              history={history}
+              onClickHintFix={this.onClickHintFix}
+              onPressEnter={this.onExecuteQuery}
+              onQueryChange={this.onChangeQuery}
+            />
+          ) : (
+            <QueryEditor
+              datasource={datasourceInstance}
+              error={queryError}
+              onQueryChange={this.onChangeQuery}
+              onExecuteQuery={this.onExecuteQuery}
+              initialQuery={initialQuery}
+              exploreEvents={exploreEvents}
+              range={range}
+            />
+          )}
+        </div>
+        <div className="query-row-tools">
+          <button className="btn navbar-button navbar-button--tight" onClick={this.onClickClearButton}>
+            <i className="fa fa-times" />
+          </button>
+          <button className="btn navbar-button navbar-button--tight" onClick={this.onClickAddButton}>
+            <i className="fa fa-plus" />
+          </button>
+          <button className="btn navbar-button navbar-button--tight" onClick={this.onClickRemoveButton}>
+            <i className="fa fa-minus" />
+          </button>
+        </div>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state: StoreState, { exploreId, index }) {
+  const explore = state.explore;
+  const item: ExploreItemState = explore[exploreId];
+  const { datasourceInstance, history, initialQueries, queryTransactions, range } = item;
+  const initialQuery = initialQueries[index];
+  return { datasourceInstance, history, initialQuery, queryTransactions, range };
+}
+
+const mapDispatchToProps = {
+  addQueryRow,
+  changeQuery,
+  highlightLogsExpression,
+  modifyQueries,
+  removeQueryRow,
+  runQueries,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(QueryRow));

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

@@ -1,159 +1,25 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
 
 
-import { QueryTransaction, HistoryItem, QueryHint } from 'app/types/explore';
 import { Emitter } from 'app/core/utils/emitter';
 import { Emitter } from 'app/core/utils/emitter';
+import { DataQuery } from 'app/types';
+import { ExploreId } from 'app/types/explore';
 
 
-// import DefaultQueryField from './QueryField';
-import QueryEditor from './QueryEditor';
-import QueryTransactionStatus from './QueryTransactionStatus';
-import { DataSource, DataQuery } from 'app/types';
-import { RawTimeRange } from '@grafana/ui';
+import QueryRow from './QueryRow';
 
 
-function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
-  const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
-  if (transaction) {
-    return transaction.hints[0];
-  }
-  return undefined;
-}
-
-interface QueryRowEventHandlers {
-  onAddQueryRow: (index: number) => void;
-  onChangeQuery: (value: DataQuery, index: number, override?: boolean) => void;
-  onClickHintFix: (action: object, index?: number) => void;
-  onExecuteQuery: () => void;
-  onRemoveQueryRow: (index: number) => void;
-}
-
-interface QueryRowCommonProps {
+interface QueryRowsProps {
   className?: string;
   className?: string;
-  datasource: DataSource;
-  history: HistoryItem[];
-  transactions: QueryTransaction[];
   exploreEvents: Emitter;
   exploreEvents: Emitter;
-  range: RawTimeRange;
-}
-
-type QueryRowProps = QueryRowCommonProps &
-  QueryRowEventHandlers & {
-    index: number;
-    initialQuery: DataQuery;
-  };
-
-class QueryRow extends PureComponent<QueryRowProps> {
-  onExecuteQuery = () => {
-    const { onExecuteQuery } = this.props;
-    onExecuteQuery();
-  };
-
-  onChangeQuery = (value: DataQuery, override?: boolean) => {
-    const { index, onChangeQuery } = this.props;
-    if (onChangeQuery) {
-      onChangeQuery(value, index, override);
-    }
-  };
-
-  onClickAddButton = () => {
-    const { index, onAddQueryRow } = this.props;
-    if (onAddQueryRow) {
-      onAddQueryRow(index);
-    }
-  };
-
-  onClickClearButton = () => {
-    this.onChangeQuery(null, true);
-  };
-
-  onClickHintFix = action => {
-    const { index, onClickHintFix } = this.props;
-    if (onClickHintFix) {
-      onClickHintFix(action, index);
-    }
-  };
-
-  onClickRemoveButton = () => {
-    const { index, onRemoveQueryRow } = this.props;
-    if (onRemoveQueryRow) {
-      onRemoveQueryRow(index);
-    }
-  };
-
-  onPressEnter = () => {
-    const { onExecuteQuery } = this.props;
-    if (onExecuteQuery) {
-      onExecuteQuery();
-    }
-  };
-
-  render() {
-    const { datasource, history, initialQuery, transactions, exploreEvents, range } = this.props;
-    const transactionWithError = transactions.find(t => t.error !== undefined);
-    const hint = getFirstHintFromTransactions(transactions);
-    const queryError = transactionWithError ? transactionWithError.error : null;
-    const QueryField = datasource.pluginExports.ExploreQueryField;
-    return (
-      <div className="query-row">
-        <div className="query-row-status">
-          <QueryTransactionStatus transactions={transactions} />
-        </div>
-        <div className="query-row-field">
-          {QueryField ? (
-            <QueryField
-              datasource={datasource}
-              error={queryError}
-              hint={hint}
-              initialQuery={initialQuery}
-              history={history}
-              onClickHintFix={this.onClickHintFix}
-              onPressEnter={this.onPressEnter}
-              onQueryChange={this.onChangeQuery}
-            />
-          ) : (
-            <QueryEditor
-              datasource={datasource}
-              error={queryError}
-              onQueryChange={this.onChangeQuery}
-              onExecuteQuery={this.onExecuteQuery}
-              initialQuery={initialQuery}
-              exploreEvents={exploreEvents}
-              range={range}
-            />
-          )}
-        </div>
-        <div className="query-row-tools">
-          <button className="btn navbar-button navbar-button--tight" onClick={this.onClickClearButton}>
-            <i className="fa fa-times" />
-          </button>
-          <button className="btn navbar-button navbar-button--tight" onClick={this.onClickAddButton}>
-            <i className="fa fa-plus" />
-          </button>
-          <button className="btn navbar-button navbar-button--tight" onClick={this.onClickRemoveButton}>
-            <i className="fa fa-minus" />
-          </button>
-        </div>
-      </div>
-    );
-  }
+  exploreId: ExploreId;
+  initialQueries: DataQuery[];
 }
 }
-
-type QueryRowsProps = QueryRowCommonProps &
-  QueryRowEventHandlers & {
-    initialQueries: DataQuery[];
-  };
-
 export default class QueryRows extends PureComponent<QueryRowsProps> {
 export default class QueryRows extends PureComponent<QueryRowsProps> {
   render() {
   render() {
-    const { className = '', initialQueries, transactions, ...handlers } = this.props;
+    const { className = '', exploreEvents, exploreId, initialQueries } = this.props;
     return (
     return (
       <div className={className}>
       <div className={className}>
         {initialQueries.map((query, index) => (
         {initialQueries.map((query, index) => (
-          <QueryRow
-            key={query.key}
-            index={index}
-            initialQuery={query}
-            transactions={transactions.filter(t => t.rowIndex === index)}
-            {...handlers}
-          />
+          // TODO instead of relying on initialQueries, move to react key list in redux
+          <QueryRow key={query.key} exploreEvents={exploreEvents} exploreId={exploreId} index={index} />
         ))}
         ))}
       </div>
       </div>
     );
     );

+ 49 - 0
public/app/features/explore/TableContainer.tsx

@@ -0,0 +1,49 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+
+import { ExploreId, ExploreItemState } from 'app/types/explore';
+import { StoreState } from 'app/types';
+
+import { toggleGraph } from './state/actions';
+import Table from './Table';
+import Panel from './Panel';
+import TableModel from 'app/core/table_model';
+
+interface TableContainerProps {
+  exploreId: ExploreId;
+  loading: boolean;
+  onClickCell: (key: string, value: string) => void;
+  showingTable: boolean;
+  tableResult?: TableModel;
+  toggleGraph: typeof toggleGraph;
+}
+
+export class TableContainer extends PureComponent<TableContainerProps> {
+  onClickTableButton = () => {
+    this.props.toggleGraph(this.props.exploreId);
+  };
+
+  render() {
+    const { loading, onClickCell, showingTable, tableResult } = this.props;
+    return (
+      <Panel label="Table" loading={loading} isOpen={showingTable} onToggle={this.onClickTableButton}>
+        <Table data={tableResult} loading={loading} onClickCell={onClickCell} />
+      </Panel>
+    );
+  }
+}
+
+function mapStateToProps(state: StoreState, { exploreId }) {
+  const explore = state.explore;
+  const item: ExploreItemState = explore[exploreId];
+  const { queryTransactions, showingTable, tableResult } = item;
+  const loading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done);
+  return { loading, showingTable, tableResult };
+}
+
+const mapDispatchToProps = {
+  toggleGraph,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(TableContainer));

+ 31 - 63
public/app/features/explore/Wrapper.tsx

@@ -3,91 +3,56 @@ import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
 import { connect } from 'react-redux';
 
 
 import { updateLocation } from 'app/core/actions';
 import { updateLocation } from 'app/core/actions';
-import { serializeStateToUrlParam, parseUrlState } from 'app/core/utils/explore';
 import { StoreState } from 'app/types';
 import { StoreState } from 'app/types';
-import { ExploreState } from 'app/types/explore';
+import { ExploreId, ExploreUrlState } from 'app/types/explore';
+import { parseUrlState } from 'app/core/utils/explore';
 
 
+import { initializeExploreSplit } from './state/actions';
 import ErrorBoundary from './ErrorBoundary';
 import ErrorBoundary from './ErrorBoundary';
 import Explore from './Explore';
 import Explore from './Explore';
 
 
 interface WrapperProps {
 interface WrapperProps {
-  backendSrv?: any;
-  datasourceSrv?: any;
+  initializeExploreSplit: typeof initializeExploreSplit;
+  split: boolean;
   updateLocation: typeof updateLocation;
   updateLocation: typeof updateLocation;
   urlStates: { [key: string]: string };
   urlStates: { [key: string]: string };
 }
 }
 
 
-interface WrapperState {
-  split: boolean;
-  splitState: ExploreState;
-}
-
-const STATE_KEY_LEFT = 'state';
-const STATE_KEY_RIGHT = 'stateRight';
-
-export class Wrapper extends Component<WrapperProps, WrapperState> {
-  urlStates: { [key: string]: string };
+export class Wrapper extends Component<WrapperProps> {
+  initialSplit: boolean;
+  urlStates: { [key: string]: ExploreUrlState };
 
 
   constructor(props: WrapperProps) {
   constructor(props: WrapperProps) {
     super(props);
     super(props);
-    this.urlStates = props.urlStates;
-    this.state = {
-      split: Boolean(props.urlStates[STATE_KEY_RIGHT]),
-      splitState: undefined,
-    };
+    this.urlStates = {};
+    const { left, right } = props.urlStates;
+    if (props.urlStates.left) {
+      this.urlStates.leftState = parseUrlState(left);
+    }
+    if (props.urlStates.right) {
+      this.urlStates.rightState = parseUrlState(right);
+      this.initialSplit = true;
+    }
   }
   }
 
 
-  onChangeSplit = (split: boolean, splitState: ExploreState) => {
-    this.setState({ split, splitState });
-    // When closing split, remove URL state for split part
-    if (!split) {
-      delete this.urlStates[STATE_KEY_RIGHT];
-      this.props.updateLocation({
-        query: this.urlStates,
-      });
+  componentDidMount() {
+    if (this.initialSplit) {
+      this.props.initializeExploreSplit();
     }
     }
-  };
-
-  onSaveState = (key: string, state: ExploreState) => {
-    const urlState = serializeStateToUrlParam(state, true);
-    this.urlStates[key] = urlState;
-    this.props.updateLocation({
-      query: this.urlStates,
-    });
-  };
+  }
 
 
   render() {
   render() {
-    const { datasourceSrv } = this.props;
-    // State overrides for props from first Explore
-    const { split, splitState } = this.state;
-    const urlStateLeft = parseUrlState(this.urlStates[STATE_KEY_LEFT]);
-    const urlStateRight = parseUrlState(this.urlStates[STATE_KEY_RIGHT]);
+    const { split } = this.props;
+    const { leftState, rightState } = this.urlStates;
 
 
     return (
     return (
       <div className="explore-wrapper">
       <div className="explore-wrapper">
         <ErrorBoundary>
         <ErrorBoundary>
-          <Explore
-            datasourceSrv={datasourceSrv}
-            onChangeSplit={this.onChangeSplit}
-            onSaveState={this.onSaveState}
-            position="left"
-            split={split}
-            stateKey={STATE_KEY_LEFT}
-            urlState={urlStateLeft}
-          />
+          <Explore exploreId={ExploreId.left} urlState={leftState} />
         </ErrorBoundary>
         </ErrorBoundary>
         {split && (
         {split && (
           <ErrorBoundary>
           <ErrorBoundary>
-            <Explore
-              datasourceSrv={datasourceSrv}
-              onChangeSplit={this.onChangeSplit}
-              onSaveState={this.onSaveState}
-              position="right"
-              split={split}
-              splitState={splitState}
-              stateKey={STATE_KEY_RIGHT}
-              urlState={urlStateRight}
-            />
+            <Explore exploreId={ExploreId.right} urlState={rightState} />
           </ErrorBoundary>
           </ErrorBoundary>
         )}
         )}
       </div>
       </div>
@@ -95,11 +60,14 @@ export class Wrapper extends Component<WrapperProps, WrapperState> {
   }
   }
 }
 }
 
 
-const mapStateToProps = (state: StoreState) => ({
-  urlStates: state.location.query,
-});
+const mapStateToProps = (state: StoreState) => {
+  const urlStates = state.location.query;
+  const { split } = state.explore;
+  return { split, urlStates };
+};
 
 
 const mapDispatchToProps = {
 const mapDispatchToProps = {
+  initializeExploreSplit,
   updateLocation,
   updateLocation,
 };
 };
 
 

+ 302 - 0
public/app/features/explore/state/actionTypes.ts

@@ -0,0 +1,302 @@
+import { RawTimeRange, TimeRange } from '@grafana/ui';
+
+import { Emitter } from 'app/core/core';
+import {
+  ExploreId,
+  ExploreItemState,
+  HistoryItem,
+  RangeScanner,
+  ResultType,
+  QueryTransaction,
+} from 'app/types/explore';
+import { DataSourceSelectItem } from 'app/types/datasources';
+import { DataQuery } from 'app/types';
+
+export enum ActionTypes {
+  AddQueryRow = 'explore/ADD_QUERY_ROW',
+  ChangeDatasource = 'explore/CHANGE_DATASOURCE',
+  ChangeQuery = 'explore/CHANGE_QUERY',
+  ChangeSize = 'explore/CHANGE_SIZE',
+  ChangeTime = 'explore/CHANGE_TIME',
+  ClearQueries = 'explore/CLEAR_QUERIES',
+  HighlightLogsExpression = 'explore/HIGHLIGHT_LOGS_EXPRESSION',
+  InitializeExplore = 'explore/INITIALIZE_EXPLORE',
+  InitializeExploreSplit = 'explore/INITIALIZE_EXPLORE_SPLIT',
+  LoadDatasourceFailure = 'explore/LOAD_DATASOURCE_FAILURE',
+  LoadDatasourceMissing = 'explore/LOAD_DATASOURCE_MISSING',
+  LoadDatasourcePending = 'explore/LOAD_DATASOURCE_PENDING',
+  LoadDatasourceSuccess = 'explore/LOAD_DATASOURCE_SUCCESS',
+  ModifyQueries = 'explore/MODIFY_QUERIES',
+  QueryTransactionFailure = 'explore/QUERY_TRANSACTION_FAILURE',
+  QueryTransactionStart = 'explore/QUERY_TRANSACTION_START',
+  QueryTransactionSuccess = 'explore/QUERY_TRANSACTION_SUCCESS',
+  RemoveQueryRow = 'explore/REMOVE_QUERY_ROW',
+  RunQueries = 'explore/RUN_QUERIES',
+  RunQueriesEmpty = 'explore/RUN_QUERIES_EMPTY',
+  ScanRange = 'explore/SCAN_RANGE',
+  ScanStart = 'explore/SCAN_START',
+  ScanStop = 'explore/SCAN_STOP',
+  SetQueries = 'explore/SET_QUERIES',
+  SplitClose = 'explore/SPLIT_CLOSE',
+  SplitOpen = 'explore/SPLIT_OPEN',
+  StateSave = 'explore/STATE_SAVE',
+  ToggleGraph = 'explore/TOGGLE_GRAPH',
+  ToggleLogs = 'explore/TOGGLE_LOGS',
+  ToggleTable = 'explore/TOGGLE_TABLE',
+}
+
+export interface AddQueryRowAction {
+  type: ActionTypes.AddQueryRow;
+  payload: {
+    exploreId: ExploreId;
+    index: number;
+    query: DataQuery;
+  };
+}
+
+export interface ChangeQueryAction {
+  type: ActionTypes.ChangeQuery;
+  payload: {
+    exploreId: ExploreId;
+    query: DataQuery;
+    index: number;
+    override: boolean;
+  };
+}
+
+export interface ChangeSizeAction {
+  type: ActionTypes.ChangeSize;
+  payload: {
+    exploreId: ExploreId;
+    width: number;
+    height: number;
+  };
+}
+
+export interface ChangeTimeAction {
+  type: ActionTypes.ChangeTime;
+  payload: {
+    exploreId: ExploreId;
+    range: TimeRange;
+  };
+}
+
+export interface ClearQueriesAction {
+  type: ActionTypes.ClearQueries;
+  payload: {
+    exploreId: ExploreId;
+  };
+}
+
+export interface HighlightLogsExpressionAction {
+  type: ActionTypes.HighlightLogsExpression;
+  payload: {
+    exploreId: ExploreId;
+    expressions: string[];
+  };
+}
+
+export interface InitializeExploreAction {
+  type: ActionTypes.InitializeExplore;
+  payload: {
+    exploreId: ExploreId;
+    containerWidth: number;
+    datasource: string;
+    eventBridge: Emitter;
+    exploreDatasources: DataSourceSelectItem[];
+    queries: DataQuery[];
+    range: RawTimeRange;
+  };
+}
+
+export interface InitializeExploreSplitAction {
+  type: ActionTypes.InitializeExploreSplit;
+}
+
+export interface LoadDatasourceFailureAction {
+  type: ActionTypes.LoadDatasourceFailure;
+  payload: {
+    exploreId: ExploreId;
+    error: string;
+  };
+}
+
+export interface LoadDatasourcePendingAction {
+  type: ActionTypes.LoadDatasourcePending;
+  payload: {
+    exploreId: ExploreId;
+    datasourceId: number;
+  };
+}
+
+export interface LoadDatasourceMissingAction {
+  type: ActionTypes.LoadDatasourceMissing;
+  payload: {
+    exploreId: ExploreId;
+  };
+}
+
+export interface LoadDatasourceSuccessAction {
+  type: ActionTypes.LoadDatasourceSuccess;
+  payload: {
+    exploreId: ExploreId;
+    StartPage?: any;
+    datasourceInstance: any;
+    history: HistoryItem[];
+    initialDatasource: string;
+    initialQueries: DataQuery[];
+    logsHighlighterExpressions?: any[];
+    showingStartPage: boolean;
+    supportsGraph: boolean;
+    supportsLogs: boolean;
+    supportsTable: boolean;
+  };
+}
+
+export interface ModifyQueriesAction {
+  type: ActionTypes.ModifyQueries;
+  payload: {
+    exploreId: ExploreId;
+    modification: any;
+    index: number;
+    modifier: (queries: DataQuery[], modification: any) => DataQuery[];
+  };
+}
+
+export interface QueryTransactionFailureAction {
+  type: ActionTypes.QueryTransactionFailure;
+  payload: {
+    exploreId: ExploreId;
+    queryTransactions: QueryTransaction[];
+  };
+}
+
+export interface QueryTransactionStartAction {
+  type: ActionTypes.QueryTransactionStart;
+  payload: {
+    exploreId: ExploreId;
+    resultType: ResultType;
+    rowIndex: number;
+    transaction: QueryTransaction;
+  };
+}
+
+export interface QueryTransactionSuccessAction {
+  type: ActionTypes.QueryTransactionSuccess;
+  payload: {
+    exploreId: ExploreId;
+    history: HistoryItem[];
+    queryTransactions: QueryTransaction[];
+  };
+}
+
+export interface RemoveQueryRowAction {
+  type: ActionTypes.RemoveQueryRow;
+  payload: {
+    exploreId: ExploreId;
+    index: number;
+  };
+}
+
+export interface RunQueriesEmptyAction {
+  type: ActionTypes.RunQueriesEmpty;
+  payload: {
+    exploreId: ExploreId;
+  };
+}
+
+export interface ScanStartAction {
+  type: ActionTypes.ScanStart;
+  payload: {
+    exploreId: ExploreId;
+    scanner: RangeScanner;
+  };
+}
+
+export interface ScanRangeAction {
+  type: ActionTypes.ScanRange;
+  payload: {
+    exploreId: ExploreId;
+    range: RawTimeRange;
+  };
+}
+
+export interface ScanStopAction {
+  type: ActionTypes.ScanStop;
+  payload: {
+    exploreId: ExploreId;
+  };
+}
+
+export interface SetQueriesAction {
+  type: ActionTypes.SetQueries;
+  payload: {
+    exploreId: ExploreId;
+    queries: DataQuery[];
+  };
+}
+
+export interface SplitCloseAction {
+  type: ActionTypes.SplitClose;
+}
+
+export interface SplitOpenAction {
+  type: ActionTypes.SplitOpen;
+  payload: {
+    itemState: ExploreItemState;
+  };
+}
+
+export interface StateSaveAction {
+  type: ActionTypes.StateSave;
+}
+
+export interface ToggleTableAction {
+  type: ActionTypes.ToggleTable;
+  payload: {
+    exploreId: ExploreId;
+  };
+}
+
+export interface ToggleGraphAction {
+  type: ActionTypes.ToggleGraph;
+  payload: {
+    exploreId: ExploreId;
+  };
+}
+
+export interface ToggleLogsAction {
+  type: ActionTypes.ToggleLogs;
+  payload: {
+    exploreId: ExploreId;
+  };
+}
+
+export type Action =
+  | AddQueryRowAction
+  | ChangeQueryAction
+  | ChangeSizeAction
+  | ChangeTimeAction
+  | ClearQueriesAction
+  | HighlightLogsExpressionAction
+  | InitializeExploreAction
+  | InitializeExploreSplitAction
+  | LoadDatasourceFailureAction
+  | LoadDatasourceMissingAction
+  | LoadDatasourcePendingAction
+  | LoadDatasourceSuccessAction
+  | ModifyQueriesAction
+  | QueryTransactionFailureAction
+  | QueryTransactionStartAction
+  | QueryTransactionSuccessAction
+  | RemoveQueryRowAction
+  | RunQueriesEmptyAction
+  | ScanRangeAction
+  | ScanStartAction
+  | ScanStopAction
+  | SetQueriesAction
+  | SplitCloseAction
+  | SplitOpenAction
+  | ToggleGraphAction
+  | ToggleLogsAction
+  | ToggleTableAction;

+ 757 - 0
public/app/features/explore/state/actions.ts

@@ -0,0 +1,757 @@
+import _ from 'lodash';
+import { ThunkAction } from 'redux-thunk';
+import { RawTimeRange, TimeRange } from '@grafana/ui';
+
+import {
+  LAST_USED_DATASOURCE_KEY,
+  clearQueryKeys,
+  ensureQueries,
+  generateEmptyQuery,
+  hasNonEmptyQuery,
+  makeTimeSeriesList,
+  updateHistory,
+  buildQueryTransaction,
+  serializeStateToUrlParam,
+} from 'app/core/utils/explore';
+
+import { updateLocation } from 'app/core/actions';
+import store from 'app/core/store';
+import { DataSourceSelectItem } from 'app/types/datasources';
+import { DataQuery, StoreState } from 'app/types';
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import {
+  ExploreId,
+  ExploreUrlState,
+  RangeScanner,
+  ResultType,
+  QueryOptions,
+  QueryTransaction,
+  QueryHint,
+  QueryHintGetter,
+} from 'app/types/explore';
+import { Emitter } from 'app/core/core';
+
+import {
+  Action as ThunkableAction,
+  ActionTypes,
+  AddQueryRowAction,
+  ChangeSizeAction,
+  HighlightLogsExpressionAction,
+  LoadDatasourceFailureAction,
+  LoadDatasourceMissingAction,
+  LoadDatasourcePendingAction,
+  LoadDatasourceSuccessAction,
+  QueryTransactionStartAction,
+  ScanStopAction,
+} from './actionTypes';
+
+type ThunkResult<R> = ThunkAction<R, StoreState, undefined, ThunkableAction>;
+
+/**
+ * Adds a query row after the row with the given index.
+ */
+export function addQueryRow(exploreId: ExploreId, index: number): AddQueryRowAction {
+  const query = generateEmptyQuery(index + 1);
+  return { type: ActionTypes.AddQueryRow, payload: { exploreId, index, query } };
+}
+
+/**
+ * Loads a new datasource identified by the given name.
+ */
+export function changeDatasource(exploreId: ExploreId, datasource: string): ThunkResult<void> {
+  return async dispatch => {
+    const instance = await getDatasourceSrv().get(datasource);
+    dispatch(loadDatasource(exploreId, instance));
+  };
+}
+
+/**
+ * Query change handler for the query row with the given index.
+ * If `override` is reset the query modifications and run the queries. Use this to set queries via a link.
+ */
+export function changeQuery(
+  exploreId: ExploreId,
+  query: DataQuery,
+  index: number,
+  override: boolean
+): ThunkResult<void> {
+  return dispatch => {
+    // Null query means reset
+    if (query === null) {
+      query = { ...generateEmptyQuery(index) };
+    }
+
+    dispatch({ type: ActionTypes.ChangeQuery, payload: { exploreId, query, index, override } });
+    if (override) {
+      dispatch(runQueries(exploreId));
+    }
+  };
+}
+
+/**
+ * Keep track of the Explore container size, in particular the width.
+ * The width will be used to calculate graph intervals (number of datapoints).
+ */
+export function changeSize(
+  exploreId: ExploreId,
+  { height, width }: { height: number; width: number }
+): ChangeSizeAction {
+  return { type: ActionTypes.ChangeSize, payload: { exploreId, height, width } };
+}
+
+/**
+ * Change the time range of Explore. Usually called from the Timepicker or a graph interaction.
+ */
+export function changeTime(exploreId: ExploreId, range: TimeRange): ThunkResult<void> {
+  return dispatch => {
+    dispatch({ type: ActionTypes.ChangeTime, payload: { exploreId, range } });
+    dispatch(runQueries(exploreId));
+  };
+}
+
+/**
+ * Clear all queries and results.
+ */
+export function clearQueries(exploreId: ExploreId): ThunkResult<void> {
+  return dispatch => {
+    dispatch(scanStop(exploreId));
+    dispatch({ type: ActionTypes.ClearQueries, payload: { exploreId } });
+    dispatch(stateSave());
+  };
+}
+
+/**
+ * Highlight expressions in the log results
+ */
+export function highlightLogsExpression(exploreId: ExploreId, expressions: string[]): HighlightLogsExpressionAction {
+  return { type: ActionTypes.HighlightLogsExpression, payload: { exploreId, expressions } };
+}
+
+/**
+ * Initialize Explore state with state from the URL and the React component.
+ * Call this only on components for with the Explore state has not been initialized.
+ */
+export function initializeExplore(
+  exploreId: ExploreId,
+  datasource: string,
+  queries: DataQuery[],
+  range: RawTimeRange,
+  containerWidth: number,
+  eventBridge: Emitter
+): ThunkResult<void> {
+  return async dispatch => {
+    const exploreDatasources: DataSourceSelectItem[] = getDatasourceSrv()
+      .getExternal()
+      .map(ds => ({
+        value: ds.name,
+        name: ds.name,
+        meta: ds.meta,
+      }));
+
+    dispatch({
+      type: ActionTypes.InitializeExplore,
+      payload: {
+        exploreId,
+        containerWidth,
+        datasource,
+        eventBridge,
+        exploreDatasources,
+        queries,
+        range,
+      },
+    });
+
+    if (exploreDatasources.length > 1) {
+      let instance;
+      if (datasource) {
+        instance = await getDatasourceSrv().get(datasource);
+      } else {
+        instance = await getDatasourceSrv().get();
+      }
+      dispatch(loadDatasource(exploreId, instance));
+    } else {
+      dispatch(loadDatasourceMissing(exploreId));
+    }
+  };
+}
+
+/**
+ * Initialize the wrapper split state
+ */
+export function initializeExploreSplit() {
+  return async dispatch => {
+    dispatch({ type: ActionTypes.InitializeExploreSplit });
+  };
+}
+
+/**
+ * Display an error that happened during the selection of a datasource
+ */
+export const loadDatasourceFailure = (exploreId: ExploreId, error: string): LoadDatasourceFailureAction => ({
+  type: ActionTypes.LoadDatasourceFailure,
+  payload: {
+    exploreId,
+    error,
+  },
+});
+
+/**
+ * Display an error when no datasources have been configured
+ */
+export const loadDatasourceMissing = (exploreId: ExploreId): LoadDatasourceMissingAction => ({
+  type: ActionTypes.LoadDatasourceMissing,
+  payload: { exploreId },
+});
+
+/**
+ * Start the async process of loading a datasource to display a loading indicator
+ */
+export const loadDatasourcePending = (exploreId: ExploreId, datasourceId: number): LoadDatasourcePendingAction => ({
+  type: ActionTypes.LoadDatasourcePending,
+  payload: {
+    exploreId,
+    datasourceId,
+  },
+});
+
+/**
+ * Datasource loading was successfully completed. The instance is stored in the state as well in case we need to
+ * run datasource-specific code. Existing queries are imported to the new datasource if an importer exists,
+ * e.g., Prometheus -> Loki queries.
+ */
+export const loadDatasourceSuccess = (
+  exploreId: ExploreId,
+  instance: any,
+  queries: DataQuery[]
+): LoadDatasourceSuccessAction => {
+  // Capabilities
+  const supportsGraph = instance.meta.metrics;
+  const supportsLogs = instance.meta.logs;
+  const supportsTable = instance.meta.tables;
+  // Custom components
+  const StartPage = instance.pluginExports.ExploreStartPage;
+
+  const historyKey = `grafana.explore.history.${instance.meta.id}`;
+  const history = store.getObject(historyKey, []);
+  // Save last-used datasource
+  store.set(LAST_USED_DATASOURCE_KEY, instance.name);
+
+  return {
+    type: ActionTypes.LoadDatasourceSuccess,
+    payload: {
+      exploreId,
+      StartPage,
+      datasourceInstance: instance,
+      history,
+      initialDatasource: instance.name,
+      initialQueries: queries,
+      showingStartPage: Boolean(StartPage),
+      supportsGraph,
+      supportsLogs,
+      supportsTable,
+    },
+  };
+};
+
+/**
+ * Main action to asynchronously load a datasource. Dispatches lots of smaller actions for feedback.
+ */
+export function loadDatasource(exploreId: ExploreId, instance: any): ThunkResult<void> {
+  return async (dispatch, getState) => {
+    const datasourceId = instance.meta.id;
+
+    // Keep ID to track selection
+    dispatch(loadDatasourcePending(exploreId, datasourceId));
+
+    let datasourceError = null;
+    try {
+      const testResult = await instance.testDatasource();
+      datasourceError = testResult.status === 'success' ? null : testResult.message;
+    } catch (error) {
+      datasourceError = (error && error.statusText) || 'Network error';
+    }
+    if (datasourceError) {
+      dispatch(loadDatasourceFailure(exploreId, datasourceError));
+      return;
+    }
+
+    if (datasourceId !== getState().explore[exploreId].requestedDatasourceId) {
+      // User already changed datasource again, discard results
+      return;
+    }
+
+    if (instance.init) {
+      instance.init();
+    }
+
+    // Check if queries can be imported from previously selected datasource
+    const queries = getState().explore[exploreId].modifiedQueries;
+    let importedQueries = queries;
+    const origin = getState().explore[exploreId].datasourceInstance;
+    if (origin) {
+      if (origin.meta.id === instance.meta.id) {
+        // Keep same queries if same type of datasource
+        importedQueries = [...queries];
+      } else if (instance.importQueries) {
+        // Datasource-specific importers
+        importedQueries = await instance.importQueries(queries, origin.meta);
+      } else {
+        // Default is blank queries
+        importedQueries = ensureQueries();
+      }
+    }
+
+    if (datasourceId !== getState().explore[exploreId].requestedDatasourceId) {
+      // User already changed datasource again, discard results
+      return;
+    }
+
+    // Reset edit state with new queries
+    const nextQueries = importedQueries.map((q, i) => ({
+      ...importedQueries[i],
+      ...generateEmptyQuery(i),
+    }));
+
+    dispatch(loadDatasourceSuccess(exploreId, instance, nextQueries));
+    dispatch(runQueries(exploreId));
+  };
+}
+
+/**
+ * Action to modify a query given a datasource-specific modifier action.
+ * @param exploreId Explore area
+ * @param modification Action object with a type, e.g., ADD_FILTER
+ * @param index Optional query row index. If omitted, the modification is applied to all query rows.
+ * @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`.
+ */
+export function modifyQueries(
+  exploreId: ExploreId,
+  modification: any,
+  index: number,
+  modifier: any
+): ThunkResult<void> {
+  return dispatch => {
+    dispatch({ type: ActionTypes.ModifyQueries, payload: { exploreId, modification, index, modifier } });
+    if (!modification.preventSubmit) {
+      dispatch(runQueries(exploreId));
+    }
+  };
+}
+
+/**
+ * Mark a query transaction as failed with an error extracted from the query response.
+ * The transaction will be marked as `done`.
+ */
+export function queryTransactionFailure(
+  exploreId: ExploreId,
+  transactionId: string,
+  response: any,
+  datasourceId: string
+): ThunkResult<void> {
+  return (dispatch, getState) => {
+    const { datasourceInstance, queryTransactions } = getState().explore[exploreId];
+    if (datasourceInstance.meta.id !== datasourceId || response.cancelled) {
+      // Navigated away, queries did not matter
+      return;
+    }
+
+    // Transaction might have been discarded
+    if (!queryTransactions.find(qt => qt.id === transactionId)) {
+      return;
+    }
+
+    console.error(response);
+
+    let error: string;
+    let errorDetails: string;
+    if (response.data) {
+      if (typeof response.data === 'string') {
+        error = response.data;
+      } else if (response.data.error) {
+        error = response.data.error;
+        if (response.data.response) {
+          errorDetails = response.data.response;
+        }
+      } else {
+        throw new Error('Could not handle error response');
+      }
+    } else if (response.message) {
+      error = response.message;
+    } else if (typeof response === 'string') {
+      error = response;
+    } else {
+      error = 'Unknown error during query transaction. Please check JS console logs.';
+    }
+
+    // Mark transactions as complete
+    const nextQueryTransactions = queryTransactions.map(qt => {
+      if (qt.id === transactionId) {
+        return {
+          ...qt,
+          error,
+          errorDetails,
+          done: true,
+        };
+      }
+      return qt;
+    });
+
+    dispatch({
+      type: ActionTypes.QueryTransactionFailure,
+      payload: { exploreId, queryTransactions: nextQueryTransactions },
+    });
+  };
+}
+
+/**
+ * Start a query transaction for the given result type.
+ * @param exploreId Explore area
+ * @param transaction Query options and `done` status.
+ * @param resultType Associate the transaction with a result viewer, e.g., Graph
+ * @param rowIndex Index is used to associate latency for this transaction with a query row
+ */
+export function queryTransactionStart(
+  exploreId: ExploreId,
+  transaction: QueryTransaction,
+  resultType: ResultType,
+  rowIndex: number
+): QueryTransactionStartAction {
+  return { type: ActionTypes.QueryTransactionStart, payload: { exploreId, resultType, rowIndex, transaction } };
+}
+
+/**
+ * Complete a query transaction, mark the transaction as `done` and store query state in URL.
+ * If the transaction was started by a scanner, it keeps on scanning for more results.
+ * Side-effect: the query is stored in localStorage.
+ * @param exploreId Explore area
+ * @param transactionId ID
+ * @param result Response from `datasourceInstance.query()`
+ * @param latency Duration between request and response
+ * @param queries Queries from all query rows
+ * @param datasourceId Origin datasource instance, used to discard results if current datasource is different
+ */
+export function queryTransactionSuccess(
+  exploreId: ExploreId,
+  transactionId: string,
+  result: any,
+  latency: number,
+  queries: DataQuery[],
+  datasourceId: string
+): ThunkResult<void> {
+  return (dispatch, getState) => {
+    const { datasourceInstance, history, queryTransactions, scanner, scanning } = getState().explore[exploreId];
+
+    // If datasource already changed, results do not matter
+    if (datasourceInstance.meta.id !== datasourceId) {
+      return;
+    }
+
+    // Transaction might have been discarded
+    const transaction = queryTransactions.find(qt => qt.id === transactionId);
+    if (!transaction) {
+      return;
+    }
+
+    // Get query hints
+    let hints: QueryHint[];
+    if (datasourceInstance.getQueryHints as QueryHintGetter) {
+      hints = datasourceInstance.getQueryHints(transaction.query, result);
+    }
+
+    // Mark transactions as complete and attach result
+    const nextQueryTransactions = queryTransactions.map(qt => {
+      if (qt.id === transactionId) {
+        return {
+          ...qt,
+          hints,
+          latency,
+          result,
+          done: true,
+        };
+      }
+      return qt;
+    });
+
+    // Side-effect: Saving history in localstorage
+    const nextHistory = updateHistory(history, datasourceId, queries);
+
+    dispatch({
+      type: ActionTypes.QueryTransactionSuccess,
+      payload: {
+        exploreId,
+        history: nextHistory,
+        queryTransactions: nextQueryTransactions,
+      },
+    });
+
+    // Keep scanning for results if this was the last scanning transaction
+    if (scanning) {
+      if (_.size(result) === 0) {
+        const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done);
+        if (!other) {
+          const range = scanner();
+          dispatch({ type: ActionTypes.ScanRange, payload: { exploreId, range } });
+        }
+      } else {
+        // We can stop scanning if we have a result
+        dispatch(scanStop(exploreId));
+      }
+    }
+  };
+}
+
+/**
+ * Remove query row of the given index, as well as associated query results.
+ */
+export function removeQueryRow(exploreId: ExploreId, index: number): ThunkResult<void> {
+  return dispatch => {
+    dispatch({ type: ActionTypes.RemoveQueryRow, payload: { exploreId, index } });
+    dispatch(runQueries(exploreId));
+  };
+}
+
+/**
+ * Main action to run queries and dispatches sub-actions based on which result viewers are active
+ */
+export function runQueries(exploreId: ExploreId) {
+  return (dispatch, getState) => {
+    const {
+      datasourceInstance,
+      modifiedQueries,
+      showingLogs,
+      showingGraph,
+      showingTable,
+      supportsGraph,
+      supportsLogs,
+      supportsTable,
+    } = getState().explore[exploreId];
+
+    if (!hasNonEmptyQuery(modifiedQueries)) {
+      dispatch({ type: ActionTypes.RunQueriesEmpty, payload: { exploreId } });
+      return;
+    }
+
+    // Some datasource's query builders allow per-query interval limits,
+    // but we're using the datasource interval limit for now
+    const interval = datasourceInstance.interval;
+
+    // Keep table queries first since they need to return quickly
+    if (showingTable && supportsTable) {
+      dispatch(
+        runQueriesForType(
+          exploreId,
+          'Table',
+          {
+            interval,
+            format: 'table',
+            instant: true,
+            valueWithRefId: true,
+          },
+          data => data[0]
+        )
+      );
+    }
+    if (showingGraph && supportsGraph) {
+      dispatch(
+        runQueriesForType(
+          exploreId,
+          'Graph',
+          {
+            interval,
+            format: 'time_series',
+            instant: false,
+          },
+          makeTimeSeriesList
+        )
+      );
+    }
+    if (showingLogs && supportsLogs) {
+      dispatch(runQueriesForType(exploreId, 'Logs', { interval, format: 'logs' }));
+    }
+    dispatch(stateSave());
+  };
+}
+
+/**
+ * Helper action to build a query transaction object and handing the query to the datasource.
+ * @param exploreId Explore area
+ * @param resultType Result viewer that will be associated with this query result
+ * @param queryOptions Query options as required by the datasource's `query()` function.
+ * @param resultGetter Optional result extractor, e.g., if the result is a list and you only need the first element.
+ */
+function runQueriesForType(
+  exploreId: ExploreId,
+  resultType: ResultType,
+  queryOptions: QueryOptions,
+  resultGetter?: any
+) {
+  return async (dispatch, getState) => {
+    const {
+      datasourceInstance,
+      eventBridge,
+      modifiedQueries: queries,
+      queryIntervals,
+      range,
+      scanning,
+    } = getState().explore[exploreId];
+    const datasourceId = datasourceInstance.meta.id;
+
+    // Run all queries concurrently
+    queries.forEach(async (query, rowIndex) => {
+      const transaction = buildQueryTransaction(
+        query,
+        rowIndex,
+        resultType,
+        queryOptions,
+        range,
+        queryIntervals,
+        scanning
+      );
+      dispatch(queryTransactionStart(exploreId, transaction, resultType, rowIndex));
+      try {
+        const now = Date.now();
+        const res = await datasourceInstance.query(transaction.options);
+        eventBridge.emit('data-received', res.data || []);
+        const latency = Date.now() - now;
+        const results = resultGetter ? resultGetter(res.data) : res.data;
+        dispatch(queryTransactionSuccess(exploreId, transaction.id, results, latency, queries, datasourceId));
+      } catch (response) {
+        eventBridge.emit('data-error', response);
+        dispatch(queryTransactionFailure(exploreId, transaction.id, response, datasourceId));
+      }
+    });
+  };
+}
+
+/**
+ * Start a scan for more results using the given scanner.
+ * @param exploreId Explore area
+ * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range
+ */
+export function scanStart(exploreId: ExploreId, scanner: RangeScanner): ThunkResult<void> {
+  return dispatch => {
+    // Register the scanner
+    dispatch({ type: ActionTypes.ScanStart, payload: { exploreId, scanner } });
+    // Scanning must trigger query run, and return the new range
+    const range = scanner();
+    // Set the new range to be displayed
+    dispatch({ type: ActionTypes.ScanRange, payload: { exploreId, range } });
+  };
+}
+
+/**
+ * Stop any scanning for more results.
+ */
+export function scanStop(exploreId: ExploreId): ScanStopAction {
+  return { type: ActionTypes.ScanStop, payload: { exploreId } };
+}
+
+/**
+ * Reset queries to the given queries. Any modifications will be discarded.
+ * Use this action for clicks on query examples. Triggers a query run.
+ */
+export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): ThunkResult<void> {
+  return dispatch => {
+    // Inject react keys into query objects
+    const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery() }));
+    dispatch({
+      type: ActionTypes.SetQueries,
+      payload: {
+        exploreId,
+        queries,
+      },
+    });
+    dispatch(runQueries(exploreId));
+  };
+}
+
+/**
+ * Close the split view and save URL state.
+ */
+export function splitClose(): ThunkResult<void> {
+  return dispatch => {
+    dispatch({ type: ActionTypes.SplitClose });
+    dispatch(stateSave());
+  };
+}
+
+/**
+ * Open the split view and copy the left state to be the right state.
+ * The right state is automatically initialized.
+ * The copy keeps all query modifications but wipes the query results.
+ */
+export function splitOpen(): ThunkResult<void> {
+  return (dispatch, getState) => {
+    // Clone left state to become the right state
+    const leftState = getState().explore.left;
+    const itemState = {
+      ...leftState,
+      queryTransactions: [],
+      initialQueries: leftState.modifiedQueries.slice(),
+    };
+    dispatch({ type: ActionTypes.SplitOpen, payload: { itemState } });
+    dispatch(stateSave());
+  };
+}
+
+/**
+ * Saves Explore state to URL using the `left` and `right` parameters.
+ * If split view is not active, `right` will not be set.
+ */
+export function stateSave() {
+  return (dispatch, getState) => {
+    const { left, right, split } = getState().explore;
+    const urlStates: { [index: string]: string } = {};
+    const leftUrlState: ExploreUrlState = {
+      datasource: left.datasourceInstance.name,
+      queries: left.modifiedQueries.map(clearQueryKeys),
+      range: left.range,
+    };
+    urlStates.left = serializeStateToUrlParam(leftUrlState, true);
+    if (split) {
+      const rightUrlState: ExploreUrlState = {
+        datasource: right.datasourceInstance.name,
+        queries: right.modifiedQueries.map(clearQueryKeys),
+        range: right.range,
+      };
+      urlStates.right = serializeStateToUrlParam(rightUrlState, true);
+    }
+    dispatch(updateLocation({ query: urlStates }));
+  };
+}
+
+/**
+ * Expand/collapse the graph result viewer. When collapsed, graph queries won't be run.
+ */
+export function toggleGraph(exploreId: ExploreId): ThunkResult<void> {
+  return (dispatch, getState) => {
+    dispatch({ type: ActionTypes.ToggleGraph, payload: { exploreId } });
+    if (getState().explore[exploreId].showingGraph) {
+      dispatch(runQueries(exploreId));
+    }
+  };
+}
+
+/**
+ * Expand/collapse the logs result viewer. When collapsed, log queries won't be run.
+ */
+export function toggleLogs(exploreId: ExploreId): ThunkResult<void> {
+  return (dispatch, getState) => {
+    dispatch({ type: ActionTypes.ToggleLogs, payload: { exploreId } });
+    if (getState().explore[exploreId].showingLogs) {
+      dispatch(runQueries(exploreId));
+    }
+  };
+}
+
+/**
+ * Expand/collapse the table result viewer. When collapsed, table queries won't be run.
+ */
+export function toggleTable(exploreId: ExploreId): ThunkResult<void> {
+  return (dispatch, getState) => {
+    dispatch({ type: ActionTypes.ToggleTable, payload: { exploreId } });
+    if (getState().explore[exploreId].showingTable) {
+      dispatch(runQueries(exploreId));
+    }
+  };
+}

+ 462 - 0
public/app/features/explore/state/reducers.ts

@@ -0,0 +1,462 @@
+import {
+  calculateResultsFromQueryTransactions,
+  generateEmptyQuery,
+  getIntervals,
+  ensureQueries,
+} from 'app/core/utils/explore';
+import { ExploreItemState, ExploreState, QueryTransaction } from 'app/types/explore';
+import { DataQuery } from 'app/types/series';
+
+import { Action, ActionTypes } from './actionTypes';
+
+export const DEFAULT_RANGE = {
+  from: 'now-6h',
+  to: 'now',
+};
+
+// Millies step for helper bar charts
+const DEFAULT_GRAPH_INTERVAL = 15 * 1000;
+
+/**
+ * Returns a fresh Explore area state
+ */
+const makeExploreItemState = (): ExploreItemState => ({
+  StartPage: undefined,
+  containerWidth: 0,
+  datasourceInstance: null,
+  datasourceError: null,
+  datasourceLoading: null,
+  datasourceMissing: false,
+  exploreDatasources: [],
+  history: [],
+  initialQueries: [],
+  initialized: false,
+  modifiedQueries: [],
+  queryTransactions: [],
+  queryIntervals: { interval: '15s', intervalMs: DEFAULT_GRAPH_INTERVAL },
+  range: DEFAULT_RANGE,
+  scanning: false,
+  scanRange: null,
+  showingGraph: true,
+  showingLogs: true,
+  showingTable: true,
+  supportsGraph: null,
+  supportsLogs: null,
+  supportsTable: null,
+});
+
+/**
+ * Global Explore state that handles multiple Explore areas and the split state
+ */
+const initialExploreState: ExploreState = {
+  split: null,
+  left: makeExploreItemState(),
+  right: makeExploreItemState(),
+};
+
+/**
+ * Reducer for an Explore area, to be used by the global Explore reducer.
+ */
+const itemReducer = (state, action: Action): ExploreItemState => {
+  switch (action.type) {
+    case ActionTypes.AddQueryRow: {
+      const { initialQueries, modifiedQueries, queryTransactions } = state;
+      const { index, query } = action.payload;
+
+      // Add new query row after given index, keep modifications of existing rows
+      const nextModifiedQueries = [
+        ...modifiedQueries.slice(0, index + 1),
+        { ...query },
+        ...initialQueries.slice(index + 1),
+      ];
+
+      // Add to initialQueries, which will cause a new row to be rendered
+      const nextQueries = [...initialQueries.slice(0, index + 1), { ...query }, ...initialQueries.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 {
+        ...state,
+        initialQueries: nextQueries,
+        logsHighlighterExpressions: undefined,
+        modifiedQueries: nextModifiedQueries,
+        queryTransactions: nextQueryTransactions,
+      };
+    }
+
+    case ActionTypes.ChangeQuery: {
+      const { initialQueries, queryTransactions } = state;
+      let { modifiedQueries } = state;
+      const { query, index, override } = action.payload;
+
+      // Fast path: only change modifiedQueries to not trigger an update
+      modifiedQueries[index] = query;
+      if (!override) {
+        return {
+          ...state,
+          modifiedQueries,
+        };
+      }
+
+      // Override path: queries are completely reset
+      const nextQuery: DataQuery = {
+        ...query,
+        ...generateEmptyQuery(index),
+      };
+      const nextQueries = [...initialQueries];
+      nextQueries[index] = nextQuery;
+      modifiedQueries = [...nextQueries];
+
+      // Discard ongoing transaction related to row query
+      const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
+
+      return {
+        ...state,
+        initialQueries: nextQueries,
+        modifiedQueries: nextQueries.slice(),
+        queryTransactions: nextQueryTransactions,
+      };
+    }
+
+    case ActionTypes.ChangeSize: {
+      const { range, datasourceInstance } = state;
+      let interval = '1s';
+      if (datasourceInstance && datasourceInstance.interval) {
+        interval = datasourceInstance.interval;
+      }
+      const containerWidth = action.payload.width;
+      const queryIntervals = getIntervals(range, interval, containerWidth);
+      return { ...state, containerWidth, queryIntervals };
+    }
+
+    case ActionTypes.ChangeTime: {
+      return {
+        ...state,
+        range: action.payload.range,
+      };
+    }
+
+    case ActionTypes.ClearQueries: {
+      const queries = ensureQueries();
+      return {
+        ...state,
+        initialQueries: queries.slice(),
+        modifiedQueries: queries.slice(),
+        queryTransactions: [],
+        showingStartPage: Boolean(state.StartPage),
+      };
+    }
+
+    case ActionTypes.HighlightLogsExpression: {
+      const { expressions } = action.payload;
+      return { ...state, logsHighlighterExpressions: expressions };
+    }
+
+    case ActionTypes.InitializeExplore: {
+      const { containerWidth, datasource, eventBridge, exploreDatasources, queries, range } = action.payload;
+      return {
+        ...state,
+        containerWidth,
+        eventBridge,
+        exploreDatasources,
+        range,
+        initialDatasource: datasource,
+        initialQueries: queries,
+        initialized: true,
+        modifiedQueries: queries.slice(),
+      };
+    }
+
+    case ActionTypes.LoadDatasourceFailure: {
+      return { ...state, datasourceError: action.payload.error, datasourceLoading: false };
+    }
+
+    case ActionTypes.LoadDatasourceMissing: {
+      return { ...state, datasourceMissing: true, datasourceLoading: false };
+    }
+
+    case ActionTypes.LoadDatasourcePending: {
+      return { ...state, datasourceLoading: true, requestedDatasourceId: action.payload.datasourceId };
+    }
+
+    case ActionTypes.LoadDatasourceSuccess: {
+      const { containerWidth, range } = state;
+      const {
+        StartPage,
+        datasourceInstance,
+        history,
+        initialDatasource,
+        initialQueries,
+        showingStartPage,
+        supportsGraph,
+        supportsLogs,
+        supportsTable,
+      } = action.payload;
+      const queryIntervals = getIntervals(range, datasourceInstance.interval, containerWidth);
+
+      return {
+        ...state,
+        queryIntervals,
+        StartPage,
+        datasourceInstance,
+        history,
+        initialDatasource,
+        initialQueries,
+        showingStartPage,
+        supportsGraph,
+        supportsLogs,
+        supportsTable,
+        datasourceLoading: false,
+        datasourceMissing: false,
+        logsHighlighterExpressions: undefined,
+        modifiedQueries: initialQueries.slice(),
+        queryTransactions: [],
+      };
+    }
+
+    case ActionTypes.ModifyQueries: {
+      const { initialQueries, modifiedQueries, queryTransactions } = state;
+      const { modification, index, modifier } = action.payload as any;
+      let nextQueries: DataQuery[];
+      let nextQueryTransactions;
+      if (index === undefined) {
+        // Modify all queries
+        nextQueries = initialQueries.map((query, i) => ({
+          ...modifier(modifiedQueries[i], modification),
+          ...generateEmptyQuery(i),
+        }));
+        // Discard all ongoing transactions
+        nextQueryTransactions = [];
+      } else {
+        // Modify query only at index
+        nextQueries = initialQueries.map((query, i) => {
+          // Synchronize all queries with local query cache to ensure consistency
+          // TODO still needed?
+          return i === index
+            ? {
+                ...modifier(modifiedQueries[i], modification),
+                ...generateEmptyQuery(i),
+              }
+            : query;
+        });
+        nextQueryTransactions = queryTransactions
+          // Consume the hint corresponding to the action
+          .map(qt => {
+            if (qt.hints != null && qt.rowIndex === index) {
+              qt.hints = qt.hints.filter(hint => hint.fix.action !== modification);
+            }
+            return qt;
+          })
+          // Preserve previous row query transaction to keep results visible if next query is incomplete
+          .filter(qt => modification.preventSubmit || qt.rowIndex !== index);
+      }
+      return {
+        ...state,
+        initialQueries: nextQueries,
+        modifiedQueries: nextQueries.slice(),
+        queryTransactions: nextQueryTransactions,
+      };
+    }
+
+    case ActionTypes.QueryTransactionFailure: {
+      const { queryTransactions } = action.payload;
+      return {
+        ...state,
+        queryTransactions,
+        showingStartPage: false,
+      };
+    }
+
+    case ActionTypes.QueryTransactionStart: {
+      const { datasourceInstance, queryIntervals, queryTransactions } = state;
+      const { resultType, rowIndex, transaction } = action.payload;
+      // Discarding existing transactions of same type
+      const remainingTransactions = queryTransactions.filter(
+        qt => !(qt.resultType === resultType && qt.rowIndex === rowIndex)
+      );
+
+      // Append new transaction
+      const nextQueryTransactions: QueryTransaction[] = [...remainingTransactions, transaction];
+
+      const results = calculateResultsFromQueryTransactions(
+        nextQueryTransactions,
+        datasourceInstance,
+        queryIntervals.intervalMs
+      );
+
+      return {
+        ...state,
+        ...results,
+        queryTransactions: nextQueryTransactions,
+        showingStartPage: false,
+      };
+    }
+
+    case ActionTypes.QueryTransactionSuccess: {
+      const { datasourceInstance, queryIntervals } = state;
+      const { history, queryTransactions } = action.payload;
+      const results = calculateResultsFromQueryTransactions(
+        queryTransactions,
+        datasourceInstance,
+        queryIntervals.intervalMs
+      );
+
+      return {
+        ...state,
+        ...results,
+        history,
+        queryTransactions,
+        showingStartPage: false,
+      };
+    }
+
+    case ActionTypes.RemoveQueryRow: {
+      const { datasourceInstance, initialQueries, queryIntervals, queryTransactions } = state;
+      let { modifiedQueries } = state;
+      const { index } = action.payload;
+
+      modifiedQueries = [...modifiedQueries.slice(0, index), ...modifiedQueries.slice(index + 1)];
+
+      if (initialQueries.length <= 1) {
+        return state;
+      }
+
+      const nextQueries = [...initialQueries.slice(0, index), ...initialQueries.slice(index + 1)];
+
+      // Discard transactions related to row query
+      const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
+      const results = calculateResultsFromQueryTransactions(
+        nextQueryTransactions,
+        datasourceInstance,
+        queryIntervals.intervalMs
+      );
+
+      return {
+        ...state,
+        ...results,
+        initialQueries: nextQueries,
+        logsHighlighterExpressions: undefined,
+        modifiedQueries: nextQueries.slice(),
+        queryTransactions: nextQueryTransactions,
+      };
+    }
+
+    case ActionTypes.RunQueriesEmpty: {
+      return { ...state, queryTransactions: [] };
+    }
+
+    case ActionTypes.ScanRange: {
+      return { ...state, scanRange: action.payload.range };
+    }
+
+    case ActionTypes.ScanStart: {
+      return { ...state, scanning: true };
+    }
+
+    case ActionTypes.ScanStop: {
+      const { queryTransactions } = state;
+      const nextQueryTransactions = queryTransactions.filter(qt => qt.scanning && !qt.done);
+      return { ...state, queryTransactions: nextQueryTransactions, scanning: false, scanRange: undefined };
+    }
+
+    case ActionTypes.SetQueries: {
+      const { queries } = action.payload;
+      return { ...state, initialQueries: queries.slice(), modifiedQueries: queries.slice() };
+    }
+
+    case ActionTypes.ToggleGraph: {
+      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 { ...state, queryTransactions: nextQueryTransactions, showingGraph };
+    }
+
+    case ActionTypes.ToggleLogs: {
+      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 { ...state, queryTransactions: nextQueryTransactions, showingLogs };
+    }
+
+    case ActionTypes.ToggleTable: {
+      const showingTable = !state.showingTable;
+      if (showingTable) {
+        return { ...state, showingTable, queryTransactions: state.queryTransactions };
+      }
+
+      // Toggle off needs discarding of table queries and results
+      const nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table');
+      const results = calculateResultsFromQueryTransactions(
+        nextQueryTransactions,
+        state.datasourceInstance,
+        state.queryIntervals.intervalMs
+      );
+
+      return { ...state, ...results, queryTransactions: nextQueryTransactions, showingTable };
+    }
+  }
+
+  return state;
+};
+
+/**
+ * Global Explore reducer that handles multiple Explore areas (left and right).
+ * Actions that have an `exploreId` get routed to the ExploreItemReducer.
+ */
+export const exploreReducer = (state = initialExploreState, action: Action): ExploreState => {
+  switch (action.type) {
+    case ActionTypes.SplitClose: {
+      return {
+        ...state,
+        split: false,
+      };
+    }
+
+    case ActionTypes.SplitOpen: {
+      return {
+        ...state,
+        split: true,
+        right: action.payload.itemState,
+      };
+    }
+
+    case ActionTypes.InitializeExploreSplit: {
+      return {
+        ...state,
+        split: true,
+      };
+    }
+  }
+
+  if (action.payload) {
+    const { exploreId } = action.payload as any;
+    if (exploreId !== undefined) {
+      const exploreItemState = state[exploreId];
+      return {
+        ...state,
+        [exploreId]: itemReducer(exploreItemState, action),
+      };
+    }
+  }
+
+  return state;
+};
+
+export default {
+  explore: exploreReducer,
+};

+ 2 - 0
public/app/store/configureStore.ts

@@ -7,6 +7,7 @@ import teamsReducers from 'app/features/teams/state/reducers';
 import apiKeysReducers from 'app/features/api-keys/state/reducers';
 import apiKeysReducers from 'app/features/api-keys/state/reducers';
 import foldersReducers from 'app/features/folders/state/reducers';
 import foldersReducers from 'app/features/folders/state/reducers';
 import dashboardReducers from 'app/features/dashboard/state/reducers';
 import dashboardReducers from 'app/features/dashboard/state/reducers';
+import exploreReducers from 'app/features/explore/state/reducers';
 import pluginReducers from 'app/features/plugins/state/reducers';
 import pluginReducers from 'app/features/plugins/state/reducers';
 import dataSourcesReducers from 'app/features/datasources/state/reducers';
 import dataSourcesReducers from 'app/features/datasources/state/reducers';
 import usersReducers from 'app/features/users/state/reducers';
 import usersReducers from 'app/features/users/state/reducers';
@@ -20,6 +21,7 @@ const rootReducers = {
   ...apiKeysReducers,
   ...apiKeysReducers,
   ...foldersReducers,
   ...foldersReducers,
   ...dashboardReducers,
   ...dashboardReducers,
+  ...exploreReducers,
   ...pluginReducers,
   ...pluginReducers,
   ...dataSourcesReducers,
   ...dataSourcesReducers,
   ...usersReducers,
   ...usersReducers,

+ 188 - 37
public/app/types/explore.ts

@@ -1,11 +1,13 @@
 import { Value } from 'slate';
 import { Value } from 'slate';
+import { RawTimeRange, TimeRange } from '@grafana/ui';
 
 
-import { DataQuery } from './series';
-import { RawTimeRange } from '@grafana/ui';
-import TableModel from 'app/core/table_model';
+import { Emitter } from 'app/core/core';
 import { LogsModel } from 'app/core/logs_model';
 import { LogsModel } from 'app/core/logs_model';
+import TableModel from 'app/core/table_model';
 import { DataSourceSelectItem } from 'app/types/datasources';
 import { DataSourceSelectItem } from 'app/types/datasources';
 
 
+import { DataQuery } from './series';
+
 export interface CompletionItem {
 export interface CompletionItem {
   /**
   /**
    * The label of this completion item. By default
    * The label of this completion item. By default
@@ -76,6 +78,174 @@ export interface CompletionItemGroup {
   skipSort?: boolean;
   skipSort?: boolean;
 }
 }
 
 
+export enum ExploreId {
+  left = 'left',
+  right = 'right',
+}
+
+/**
+ * Global Explore state
+ */
+export interface ExploreState {
+  /**
+   * True if split view is active.
+   */
+  split: boolean;
+  /**
+   * Explore state of the left split (left is default in non-split view).
+   */
+  left: ExploreItemState;
+  /**
+   * Explore state of the right area in split view.
+   */
+  right: ExploreItemState;
+}
+
+export interface ExploreItemState {
+  /**
+   * React component to be shown when no queries have been run yet, e.g., for a query language cheat sheet.
+   */
+  StartPage?: any;
+  /**
+   * Width used for calculating the graph interval (can't have more datapoints than pixels)
+   */
+  containerWidth: number;
+  /**
+   * Datasource instance that has been selected. Datasource-specific logic can be run on this object.
+   */
+  datasourceInstance: any;
+  /**
+   * Error to be shown when datasource loading or testing failed.
+   */
+  datasourceError: string;
+  /**
+   * True if the datasource is loading. `null` if the loading has not started yet.
+   */
+  datasourceLoading: boolean | null;
+  /**
+   * True if there is no datasource to be selected.
+   */
+  datasourceMissing: boolean;
+  /**
+   * Emitter to send events to the rest of Grafana.
+   */
+  eventBridge?: Emitter;
+  /**
+   * List of datasources to be shown in the datasource selector.
+   */
+  exploreDatasources: DataSourceSelectItem[];
+  /**
+   * List of timeseries to be shown in the Explore graph result viewer.
+   */
+  graphResult?: any[];
+  /**
+   * History of recent queries. Datasource-specific and initialized via localStorage.
+   */
+  history: HistoryItem[];
+  /**
+   * Initial datasource for this Explore, e.g., set via URL.
+   */
+  initialDatasource?: string;
+  /**
+   * Initial queries for this Explore, e.g., set via URL. Each query will be
+   * converted to a query row. Query edits should be tracked in `modifiedQueries` though.
+   */
+  initialQueries: DataQuery[];
+  /**
+   * True if this Explore area has been initialized.
+   * Used to distinguish URL state injection versus split view state injection.
+   */
+  initialized: boolean;
+  /**
+   * Log line substrings to be highlighted as you type in a query field.
+   * Currently supports only the first query row.
+   */
+  logsHighlighterExpressions?: string[];
+  /**
+   * Log query result to be displayed in the logs result viewer.
+   */
+  logsResult?: LogsModel;
+  /**
+   * Copy of `initialQueries` that tracks user edits.
+   * Don't connect this property to a react component as it is updated on every query change.
+   * Used when running queries. Needs to be reset to `initialQueries` when those are reset as well.
+   */
+  modifiedQueries: DataQuery[];
+  /**
+   * Query intervals for graph queries to determine how many datapoints to return.
+   * Needs to be updated when `datasourceInstance` or `containerWidth` is changed.
+   */
+  queryIntervals: QueryIntervals;
+  /**
+   * List of query transaction to track query duration and query result.
+   * Graph/Logs/Table results are calculated on the fly from the transaction,
+   * based on the transaction's result types. Transaction also holds the row index
+   * so that results can be dropped and re-computed without running queries again
+   * when query rows are removed.
+   */
+  queryTransactions: QueryTransaction[];
+  /**
+   * Tracks datasource when selected in the datasource selector.
+   * Allows the selection to be discarded if something went wrong during the asynchronous
+   * loading of the datasource.
+   */
+  requestedDatasourceId?: number;
+  /**
+   * Time range for this Explore. Managed by the time picker and used by all query runs.
+   */
+  range: TimeRange | RawTimeRange;
+  /**
+   * Scanner function that calculates a new range, triggers a query run, and returns the new range.
+   */
+  scanner?: RangeScanner;
+  /**
+   * True if scanning for more results is active.
+   */
+  scanning?: boolean;
+  /**
+   * Current scanning range to be shown to the user while scanning is active.
+   */
+  scanRange?: RawTimeRange;
+  /**
+   * True if graph result viewer is expanded. Query runs will contain graph queries.
+   */
+  showingGraph: boolean;
+  /**
+   * True if logs result viewer is expanded. Query runs will contain logs queries.
+   */
+  showingLogs: boolean;
+  /**
+   * True StartPage needs to be shown. Typically set to `false` once queries have been run.
+   */
+  showingStartPage?: boolean;
+  /**
+   * True if table result viewer is expanded. Query runs will contain table queries.
+   */
+  showingTable: boolean;
+  /**
+   * True if `datasourceInstance` supports graph queries.
+   */
+  supportsGraph: boolean | null;
+  /**
+   * True if `datasourceInstance` supports logs queries.
+   */
+  supportsLogs: boolean | null;
+  /**
+   * True if `datasourceInstance` supports table queries.
+   */
+  supportsTable: boolean | null;
+  /**
+   * Table model that combines all query table results into a single table.
+   */
+  tableResult?: TableModel;
+}
+
+export interface ExploreUrlState {
+  datasource: string;
+  queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense
+  range: RawTimeRange;
+}
+
 export interface HistoryItem {
 export interface HistoryItem {
   ts: number;
   ts: number;
   query: DataQuery;
   query: DataQuery;
@@ -128,6 +298,19 @@ export interface QueryHintGetter {
   (query: DataQuery, results: any[], ...rest: any): QueryHint[];
   (query: DataQuery, results: any[], ...rest: any): QueryHint[];
 }
 }
 
 
+export interface QueryIntervals {
+  interval: string;
+  intervalMs: number;
+}
+
+export interface QueryOptions {
+  interval: string;
+  format: string;
+  hinting?: boolean;
+  instant?: boolean;
+  valueWithRefId?: boolean;
+}
+
 export interface QueryTransaction {
 export interface QueryTransaction {
   id: string;
   id: string;
   done: boolean;
   done: boolean;
@@ -142,6 +325,8 @@ export interface QueryTransaction {
   scanning?: boolean;
   scanning?: boolean;
 }
 }
 
 
+export type RangeScanner = () => RawTimeRange;
+
 export interface TextMatch {
 export interface TextMatch {
   text: string;
   text: string;
   start: number;
   start: number;
@@ -149,38 +334,4 @@ export interface TextMatch {
   end: number;
   end: number;
 }
 }
 
 
-export interface ExploreState {
-  StartPage?: any;
-  datasource: any;
-  datasourceError: any;
-  datasourceLoading: boolean | null;
-  datasourceMissing: boolean;
-  exploreDatasources: DataSourceSelectItem[];
-  graphInterval: number; // in ms
-  graphResult?: any[];
-  history: HistoryItem[];
-  initialDatasource?: string;
-  initialQueries: DataQuery[];
-  logsHighlighterExpressions?: string[];
-  logsResult?: LogsModel;
-  queryTransactions: QueryTransaction[];
-  range: RawTimeRange;
-  scanning?: boolean;
-  scanRange?: RawTimeRange;
-  showingGraph: boolean;
-  showingLogs: boolean;
-  showingStartPage?: boolean;
-  showingTable: boolean;
-  supportsGraph: boolean | null;
-  supportsLogs: boolean | null;
-  supportsTable: boolean | null;
-  tableResult?: TableModel;
-}
-
-export interface ExploreUrlState {
-  datasource: string;
-  queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense
-  range: RawTimeRange;
-}
-
 export type ResultType = 'Graph' | 'Logs' | 'Table';
 export type ResultType = 'Graph' | 'Logs' | 'Table';

+ 2 - 0
public/app/types/index.ts

@@ -19,6 +19,7 @@ import {
 } from './appNotifications';
 } from './appNotifications';
 import { DashboardSearchHit } from './search';
 import { DashboardSearchHit } from './search';
 import { ValidationEvents, ValidationRule } from './form';
 import { ValidationEvents, ValidationRule } from './form';
+import { ExploreState } from './explore';
 export {
 export {
   Team,
   Team,
   TeamsState,
   TeamsState,
@@ -81,6 +82,7 @@ export interface StoreState {
   folder: FolderState;
   folder: FolderState;
   dashboard: DashboardState;
   dashboard: DashboardState;
   dataSources: DataSourcesState;
   dataSources: DataSourcesState;
+  explore: ExploreState;
   users: UsersState;
   users: UsersState;
   organization: OrganizationState;
   organization: OrganizationState;
   appNotifications: AppNotificationsState;
   appNotifications: AppNotificationsState;

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

@@ -1,5 +1,5 @@
 .explore {
 .explore {
-  width: 100%;
+  flex: 1 1 auto;
 
 
   &-container {
   &-container {
     padding: $dashboard-padding;
     padding: $dashboard-padding;

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác