Procházet zdrojové kódy

WIP Explore redux migration

David Kaltschmidt před 7 roky
rodič
revize
2be2deddb8

+ 77 - 12
public/app/core/utils/explore.ts

@@ -1,6 +1,7 @@
 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 kbn from 'app/core/utils/kbn';
 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 TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
-import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore';
+import {
+  ExploreUrlState,
+  HistoryItem,
+  QueryTransaction,
+  ResultType,
+  QueryIntervals,
+  QueryOptions,
+} from 'app/types/explore';
 import { DataQuery, DataSourceApi } from 'app/types/series';
-import { RawTimeRange, IntervalValues } from '@grafana/ui';
 
 export const DEFAULT_RANGE = {
   from: 'now-6h',
@@ -19,6 +26,8 @@ export const DEFAULT_RANGE = {
 
 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.
  *
@@ -77,6 +86,62 @@ export async function getExploreUrl(
   return url;
 }
 
+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,
+  };
+}
+
 const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
 
 export function parseUrlState(initial: string | undefined): ExploreUrlState {
@@ -103,12 +168,12 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
   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 {
+  // const urlState: ExploreUrlState = {
+  //   datasource: state.initialDatasource,
+  //   queries: state.initialQueries.map(clearQueryKeys),
+  //   range: state.range,
+  // };
   if (compact) {
     return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]);
   }
@@ -123,7 +188,7 @@ export function generateRefId(index = 0): string {
   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) };
 }
 
@@ -132,9 +197,9 @@ export function generateQueryKeys(index = 0): { refId: string; key: string } {
  */
 export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
   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() }];
 }
 
 /**

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 135 - 716
public/app/features/explore/Explore.tsx


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

@@ -0,0 +1,694 @@
+import _ from 'lodash';
+import { ThunkAction } from 'redux-thunk';
+import { RawTimeRange, TimeRange } from '@grafana/ui';
+
+import {
+  LAST_USED_DATASOURCE_KEY,
+  ensureQueries,
+  generateEmptyQuery,
+  hasNonEmptyQuery,
+  makeTimeSeriesList,
+  updateHistory,
+  buildQueryTransaction,
+} from 'app/core/utils/explore';
+
+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 {
+  HistoryItem,
+  RangeScanner,
+  ResultType,
+  QueryOptions,
+  QueryTransaction,
+  QueryHint,
+  QueryHintGetter,
+} from 'app/types/explore';
+import { Emitter } from 'app/core/core';
+import { dispatch } from 'rxjs/internal/observable/pairs';
+
+export enum ActionTypes {
+  AddQueryRow = 'ADD_QUERY_ROW',
+  ChangeDatasource = 'CHANGE_DATASOURCE',
+  ChangeQuery = 'CHANGE_QUERY',
+  ChangeSize = 'CHANGE_SIZE',
+  ChangeTime = 'CHANGE_TIME',
+  ClickClear = 'CLICK_CLEAR',
+  ClickExample = 'CLICK_EXAMPLE',
+  ClickGraphButton = 'CLICK_GRAPH_BUTTON',
+  ClickLogsButton = 'CLICK_LOGS_BUTTON',
+  ClickTableButton = 'CLICK_TABLE_BUTTON',
+  HighlightLogsExpression = 'HIGHLIGHT_LOGS_EXPRESSION',
+  InitializeExplore = 'INITIALIZE_EXPLORE',
+  LoadDatasourceFailure = 'LOAD_DATASOURCE_FAILURE',
+  LoadDatasourceMissing = 'LOAD_DATASOURCE_MISSING',
+  LoadDatasourcePending = 'LOAD_DATASOURCE_PENDING',
+  LoadDatasourceSuccess = 'LOAD_DATASOURCE_SUCCESS',
+  ModifyQueries = 'MODIFY_QUERIES',
+  QueryTransactionFailure = 'QUERY_TRANSACTION_FAILURE',
+  QueryTransactionStart = 'QUERY_TRANSACTION_START',
+  QueryTransactionSuccess = 'QUERY_TRANSACTION_SUCCESS',
+  RemoveQueryRow = 'REMOVE_QUERY_ROW',
+  RunQueries = 'RUN_QUERIES',
+  RunQueriesEmpty = 'RUN_QUERIES',
+  ScanRange = 'SCAN_RANGE',
+  ScanStart = 'SCAN_START',
+  ScanStop = 'SCAN_STOP',
+}
+
+export interface AddQueryRowAction {
+  type: ActionTypes.AddQueryRow;
+  index: number;
+  query: DataQuery;
+}
+
+export interface ChangeQueryAction {
+  type: ActionTypes.ChangeQuery;
+  query: DataQuery;
+  index: number;
+  override: boolean;
+}
+
+export interface ChangeSizeAction {
+  type: ActionTypes.ChangeSize;
+  width: number;
+  height: number;
+}
+
+export interface ChangeTimeAction {
+  type: ActionTypes.ChangeTime;
+  range: TimeRange;
+}
+
+export interface ClickClearAction {
+  type: ActionTypes.ClickClear;
+}
+
+export interface ClickExampleAction {
+  type: ActionTypes.ClickExample;
+  query: DataQuery;
+}
+
+export interface ClickGraphButtonAction {
+  type: ActionTypes.ClickGraphButton;
+}
+
+export interface ClickLogsButtonAction {
+  type: ActionTypes.ClickLogsButton;
+}
+
+export interface ClickTableButtonAction {
+  type: ActionTypes.ClickTableButton;
+}
+
+export interface InitializeExploreAction {
+  type: ActionTypes.InitializeExplore;
+  containerWidth: number;
+  datasource: string;
+  eventBridge: Emitter;
+  exploreDatasources: DataSourceSelectItem[];
+  queries: DataQuery[];
+  range: RawTimeRange;
+}
+
+export interface HighlightLogsExpressionAction {
+  type: ActionTypes.HighlightLogsExpression;
+  expressions: string[];
+}
+
+export interface LoadDatasourceFailureAction {
+  type: ActionTypes.LoadDatasourceFailure;
+  error: string;
+}
+
+export interface LoadDatasourcePendingAction {
+  type: ActionTypes.LoadDatasourcePending;
+  datasourceId: number;
+}
+
+export interface LoadDatasourceMissingAction {
+  type: ActionTypes.LoadDatasourceMissing;
+}
+
+export interface LoadDatasourceSuccessAction {
+  type: ActionTypes.LoadDatasourceSuccess;
+  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;
+  modification: any;
+  index: number;
+  modifier: (queries: DataQuery[], modification: any) => DataQuery[];
+}
+
+export interface QueryTransactionFailureAction {
+  type: ActionTypes.QueryTransactionFailure;
+  queryTransactions: QueryTransaction[];
+}
+
+export interface QueryTransactionStartAction {
+  type: ActionTypes.QueryTransactionStart;
+  resultType: ResultType;
+  rowIndex: number;
+  transaction: QueryTransaction;
+}
+
+export interface QueryTransactionSuccessAction {
+  type: ActionTypes.QueryTransactionSuccess;
+  history: HistoryItem[];
+  queryTransactions: QueryTransaction[];
+}
+
+export interface RemoveQueryRowAction {
+  type: ActionTypes.RemoveQueryRow;
+  index: number;
+}
+
+export interface ScanStartAction {
+  type: ActionTypes.ScanStart;
+  scanner: RangeScanner;
+}
+
+export interface ScanRangeAction {
+  type: ActionTypes.ScanRange;
+  range: RawTimeRange;
+}
+
+export interface ScanStopAction {
+  type: ActionTypes.ScanStop;
+}
+
+export type Action =
+  | AddQueryRowAction
+  | ChangeQueryAction
+  | ChangeSizeAction
+  | ChangeTimeAction
+  | ClickClearAction
+  | ClickExampleAction
+  | ClickGraphButtonAction
+  | ClickLogsButtonAction
+  | ClickTableButtonAction
+  | HighlightLogsExpressionAction
+  | InitializeExploreAction
+  | LoadDatasourceFailureAction
+  | LoadDatasourceMissingAction
+  | LoadDatasourcePendingAction
+  | LoadDatasourceSuccessAction
+  | ModifyQueriesAction
+  | QueryTransactionFailureAction
+  | QueryTransactionStartAction
+  | QueryTransactionSuccessAction
+  | RemoveQueryRowAction
+  | ScanRangeAction
+  | ScanStartAction
+  | ScanStopAction;
+type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
+
+export function addQueryRow(index: number): AddQueryRowAction {
+  const query = generateEmptyQuery(index + 1);
+  return { type: ActionTypes.AddQueryRow, index, query };
+}
+
+export function changeDatasource(datasource: string): ThunkResult<void> {
+  return async dispatch => {
+    const instance = await getDatasourceSrv().get(datasource);
+    dispatch(loadDatasource(instance));
+  };
+}
+
+export function changeQuery(query: DataQuery, index: number, override: boolean): ThunkResult<void> {
+  return dispatch => {
+    // Null query means reset
+    if (query === null) {
+      query = { ...generateEmptyQuery(index) };
+    }
+
+    dispatch({ type: ActionTypes.ChangeQuery, query, index, override });
+    if (override) {
+      dispatch(runQueries());
+    }
+  };
+}
+
+export function changeSize({ height, width }: { height: number; width: number }): ChangeSizeAction {
+  return { type: ActionTypes.ChangeSize, height, width };
+}
+
+export function changeTime(range: TimeRange): ThunkResult<void> {
+  return dispatch => {
+    dispatch({ type: ActionTypes.ChangeTime, range });
+    dispatch(runQueries());
+  };
+}
+
+export function clickExample(rawQuery: DataQuery): ThunkResult<void> {
+  return dispatch => {
+    const query = { ...rawQuery, ...generateEmptyQuery() };
+    dispatch({
+      type: ActionTypes.ClickExample,
+      query,
+    });
+    dispatch(runQueries());
+  };
+}
+
+export function clickClear(): ThunkResult<void> {
+  return dispatch => {
+    dispatch(scanStop());
+    dispatch({ type: ActionTypes.ClickClear });
+    // TODO save state
+  };
+}
+
+export function clickGraphButton(): ThunkResult<void> {
+  return (dispatch, getState) => {
+    dispatch({ type: ActionTypes.ClickGraphButton });
+    if (getState().explore.showingGraph) {
+      dispatch(runQueries());
+    }
+  };
+}
+
+export function clickLogsButton(): ThunkResult<void> {
+  return (dispatch, getState) => {
+    dispatch({ type: ActionTypes.ClickLogsButton });
+    if (getState().explore.showingLogs) {
+      dispatch(runQueries());
+    }
+  };
+}
+
+export function clickTableButton(): ThunkResult<void> {
+  return (dispatch, getState) => {
+    dispatch({ type: ActionTypes.ClickTableButton });
+    if (getState().explore.showingTable) {
+      dispatch(runQueries());
+    }
+  };
+}
+
+export function highlightLogsExpression(expressions: string[]): HighlightLogsExpressionAction {
+  return { type: ActionTypes.HighlightLogsExpression, expressions };
+}
+
+export function initializeExplore(
+  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,
+      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(instance));
+    } else {
+      dispatch(loadDatasourceMissing);
+    }
+  };
+}
+
+export const loadDatasourceFailure = (error: string): LoadDatasourceFailureAction => ({
+  type: ActionTypes.LoadDatasourceFailure,
+  error,
+});
+
+export const loadDatasourceMissing: LoadDatasourceMissingAction = { type: ActionTypes.LoadDatasourceMissing };
+
+export const loadDatasourcePending = (datasourceId: number): LoadDatasourcePendingAction => ({
+  type: ActionTypes.LoadDatasourcePending,
+  datasourceId,
+});
+
+export const loadDatasourceSuccess = (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,
+    StartPage,
+    datasourceInstance: instance,
+    history,
+    initialDatasource: instance.name,
+    initialQueries: queries,
+    showingStartPage: Boolean(StartPage),
+    supportsGraph,
+    supportsLogs,
+    supportsTable,
+  };
+};
+
+export function loadDatasource(instance: any): ThunkResult<void> {
+  return async (dispatch, getState) => {
+    const datasourceId = instance.meta.id;
+
+    // Keep ID to track selection
+    dispatch(loadDatasourcePending(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(datasourceError));
+      return;
+    }
+
+    if (datasourceId !== getState().explore.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.modifiedQueries;
+    let importedQueries = queries;
+    const origin = getState().explore.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.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(instance, nextQueries));
+    dispatch(runQueries());
+  };
+}
+
+export function modifyQueries(modification: any, index: number, modifier: any): ThunkResult<void> {
+  return dispatch => {
+    dispatch({ type: ActionTypes.ModifyQueries, modification, index, modifier });
+    if (!modification.preventSubmit) {
+      dispatch(runQueries());
+    }
+  };
+}
+
+export function queryTransactionFailure(transactionId: string, response: any, datasourceId: string): ThunkResult<void> {
+  return (dispatch, getState) => {
+    const { datasourceInstance, queryTransactions } = getState().explore;
+    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 null;
+    }
+
+    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, queryTransactions: nextQueryTransactions });
+  };
+}
+
+export function queryTransactionStart(
+  transaction: QueryTransaction,
+  resultType: ResultType,
+  rowIndex: number
+): QueryTransactionStartAction {
+  return { type: ActionTypes.QueryTransactionStart, resultType, rowIndex, transaction };
+}
+
+export function queryTransactionSuccess(
+  transactionId: string,
+  result: any,
+  latency: number,
+  queries: DataQuery[],
+  datasourceId: string
+): ThunkResult<void> {
+  return (dispatch, getState) => {
+    const { datasourceInstance, history, queryTransactions, scanner, scanning } = getState().explore;
+
+    // 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,
+      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, range });
+        }
+      } else {
+        // We can stop scanning if we have a result
+        dispatch(scanStop());
+      }
+    }
+  };
+}
+
+export function removeQueryRow(index: number): ThunkResult<void> {
+  return dispatch => {
+    dispatch({ type: ActionTypes.RemoveQueryRow, index });
+    dispatch(runQueries());
+  };
+}
+
+export function runQueries() {
+  return (dispatch, getState) => {
+    const {
+      datasourceInstance,
+      modifiedQueries,
+      showingLogs,
+      showingGraph,
+      showingTable,
+      supportsGraph,
+      supportsLogs,
+      supportsTable,
+    } = getState().explore;
+
+    if (!hasNonEmptyQuery(modifiedQueries)) {
+      dispatch({ type: ActionTypes.RunQueriesEmpty });
+      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(
+          'Table',
+          {
+            interval,
+            format: 'table',
+            instant: true,
+            valueWithRefId: true,
+          },
+          data => data[0]
+        )
+      );
+    }
+    if (showingGraph && supportsGraph) {
+      dispatch(
+        runQueriesForType(
+          'Graph',
+          {
+            interval,
+            format: 'time_series',
+            instant: false,
+          },
+          makeTimeSeriesList
+        )
+      );
+    }
+    if (showingLogs && supportsLogs) {
+      dispatch(runQueriesForType('Logs', { interval, format: 'logs' }));
+    }
+    // TODO save state
+  };
+}
+
+function runQueriesForType(resultType: ResultType, queryOptions: QueryOptions, resultGetter?: any) {
+  return async (dispatch, getState) => {
+    const {
+      datasourceInstance,
+      eventBridge,
+      modifiedQueries: queries,
+      queryIntervals,
+      range,
+      scanning,
+    } = getState().explore;
+    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(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(transaction.id, results, latency, queries, datasourceId));
+      } catch (response) {
+        eventBridge.emit('data-error', response);
+        dispatch(queryTransactionFailure(transaction.id, response, datasourceId));
+      }
+    });
+  };
+}
+
+export function scanStart(scanner: RangeScanner): ThunkResult<void> {
+  return dispatch => {
+    dispatch({ type: ActionTypes.ScanStart, scanner });
+    const range = scanner();
+    dispatch({ type: ActionTypes.ScanRange, range });
+  };
+}
+
+export function scanStop(): ScanStopAction {
+  return { type: ActionTypes.ScanStop };
+}

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

@@ -0,0 +1,412 @@
+import { RawTimeRange, TimeRange } from '@grafana/ui';
+
+import {
+  calculateResultsFromQueryTransactions,
+  generateEmptyQuery,
+  getIntervals,
+  ensureQueries,
+} from 'app/core/utils/explore';
+import { DataSourceSelectItem } from 'app/types/datasources';
+import { HistoryItem, QueryTransaction, QueryIntervals, RangeScanner } from 'app/types/explore';
+import { DataQuery } from 'app/types/series';
+
+import { Action, ActionTypes } from './actions';
+import { Emitter } from 'app/core/core';
+import { LogsModel } from 'app/core/logs_model';
+import TableModel from 'app/core/table_model';
+
+// TODO move to types
+export interface ExploreState {
+  StartPage?: any;
+  containerWidth: number;
+  datasourceInstance: any;
+  datasourceError: string;
+  datasourceLoading: boolean | null;
+  datasourceMissing: boolean;
+  eventBridge?: Emitter;
+  exploreDatasources: DataSourceSelectItem[];
+  graphResult?: any[];
+  history: HistoryItem[];
+  initialDatasource?: string;
+  initialQueries: DataQuery[];
+  logsHighlighterExpressions?: string[];
+  logsResult?: LogsModel;
+  modifiedQueries: DataQuery[];
+  queryIntervals: QueryIntervals;
+  queryTransactions: QueryTransaction[];
+  requestedDatasourceId?: number;
+  range: TimeRange | RawTimeRange;
+  scanner?: RangeScanner;
+  scanning?: boolean;
+  scanRange?: RawTimeRange;
+  showingGraph: boolean;
+  showingLogs: boolean;
+  showingStartPage?: boolean;
+  showingTable: boolean;
+  supportsGraph: boolean | null;
+  supportsLogs: boolean | null;
+  supportsTable: boolean | null;
+  tableResult?: TableModel;
+}
+
+export const DEFAULT_RANGE = {
+  from: 'now-6h',
+  to: 'now',
+};
+
+// Millies step for helper bar charts
+const DEFAULT_GRAPH_INTERVAL = 15 * 1000;
+
+const initialExploreState: ExploreState = {
+  StartPage: undefined,
+  containerWidth: 0,
+  datasourceInstance: null,
+  datasourceError: null,
+  datasourceLoading: null,
+  datasourceMissing: false,
+  exploreDatasources: [],
+  history: [],
+  initialQueries: [],
+  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,
+};
+
+export const exploreReducer = (state = initialExploreState, action: Action): ExploreState => {
+  switch (action.type) {
+    case ActionTypes.AddQueryRow: {
+      const { initialQueries, modifiedQueries, queryTransactions } = state;
+      const { index, query } = action;
+      modifiedQueries[index + 1] = query;
+
+      const nextQueries = [
+        ...initialQueries.slice(0, index + 1),
+        { ...modifiedQueries[index + 1] },
+        ...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,
+        modifiedQueries,
+        initialQueries: nextQueries,
+        logsHighlighterExpressions: undefined,
+        queryTransactions: nextQueryTransactions,
+      };
+    }
+
+    case ActionTypes.ChangeQuery: {
+      const { initialQueries, queryTransactions } = state;
+      let { modifiedQueries } = state;
+      const { query, index, override } = action;
+      modifiedQueries[index] = query;
+      if (override) {
+        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,
+        };
+      }
+      return {
+        ...state,
+        modifiedQueries,
+      };
+    }
+
+    case ActionTypes.ChangeSize: {
+      const { range, datasourceInstance } = state;
+      if (!datasourceInstance) {
+        return state;
+      }
+      const containerWidth = action.width;
+      const queryIntervals = getIntervals(range, datasourceInstance.interval, containerWidth);
+      return { ...state, containerWidth, queryIntervals };
+    }
+
+    case ActionTypes.ChangeTime: {
+      return {
+        ...state,
+        range: action.range,
+      };
+    }
+
+    case ActionTypes.ClickClear: {
+      const queries = ensureQueries();
+      return {
+        ...state,
+        initialQueries: queries.slice(),
+        modifiedQueries: queries.slice(),
+        showingStartPage: Boolean(state.StartPage),
+      };
+    }
+
+    case ActionTypes.ClickExample: {
+      const modifiedQueries = [action.query];
+      return { ...state, initialQueries: modifiedQueries.slice(), modifiedQueries };
+    }
+
+    case ActionTypes.ClickGraphButton: {
+      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.ClickLogsButton: {
+      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.ClickTableButton: {
+      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 };
+    }
+
+    case ActionTypes.InitializeExplore: {
+      const { containerWidth, eventBridge, exploreDatasources, range } = action;
+      return {
+        ...state,
+        containerWidth,
+        eventBridge,
+        exploreDatasources,
+        range,
+        initialDatasource: action.datasource,
+        initialQueries: action.queries,
+        modifiedQueries: action.queries.slice(),
+      };
+    }
+
+    case ActionTypes.LoadDatasourceFailure: {
+      return { ...state, datasourceError: action.error, datasourceLoading: false };
+    }
+
+    case ActionTypes.LoadDatasourceMissing: {
+      return { ...state, datasourceMissing: true, datasourceLoading: false };
+    }
+
+    case ActionTypes.LoadDatasourcePending: {
+      return { ...state, datasourceLoading: true, requestedDatasourceId: action.datasourceId };
+    }
+
+    case ActionTypes.LoadDatasourceSuccess: {
+      const { containerWidth, range } = state;
+      const queryIntervals = getIntervals(range, action.datasourceInstance.interval, containerWidth);
+
+      return {
+        ...state,
+        queryIntervals,
+        StartPage: action.StartPage,
+        datasourceInstance: action.datasourceInstance,
+        datasourceLoading: false,
+        datasourceMissing: false,
+        history: action.history,
+        initialDatasource: action.initialDatasource,
+        initialQueries: action.initialQueries,
+        logsHighlighterExpressions: undefined,
+        modifiedQueries: action.initialQueries.slice(),
+        showingStartPage: action.showingStartPage,
+        supportsGraph: action.supportsGraph,
+        supportsLogs: action.supportsLogs,
+        supportsTable: action.supportsTable,
+      };
+    }
+
+    case ActionTypes.ModifyQueries: {
+      const { initialQueries, modifiedQueries, queryTransactions } = state;
+      const { action: modification, index, modifier } = action 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.RemoveQueryRow: {
+      const { datasourceInstance, initialQueries, queryIntervals, queryTransactions } = state;
+      let { modifiedQueries } = state;
+      const { index } = action;
+
+      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.QueryTransactionFailure: {
+      const { queryTransactions } = action;
+      return {
+        ...state,
+        queryTransactions,
+        showingStartPage: false,
+      };
+    }
+
+    case ActionTypes.QueryTransactionStart: {
+      const { datasourceInstance, queryIntervals, queryTransactions } = state;
+      const { resultType, rowIndex, transaction } = action;
+      // 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;
+      const results = calculateResultsFromQueryTransactions(
+        queryTransactions,
+        datasourceInstance,
+        queryIntervals.intervalMs
+      );
+
+      return {
+        ...state,
+        ...results,
+        history,
+        queryTransactions,
+        showingStartPage: false,
+      };
+    }
+
+    case ActionTypes.ScanRange: {
+      return { ...state, scanRange: action.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 };
+    }
+  }
+
+  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 foldersReducers from 'app/features/folders/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 dataSourcesReducers from 'app/features/datasources/state/reducers';
 import usersReducers from 'app/features/users/state/reducers';
@@ -20,6 +21,7 @@ const rootReducers = {
   ...apiKeysReducers,
   ...foldersReducers,
   ...dashboardReducers,
+  ...exploreReducers,
   ...pluginReducers,
   ...dataSourcesReducers,
   ...usersReducers,

+ 15 - 8
public/app/types/explore.ts

@@ -4,7 +4,6 @@ import { DataQuery } from './series';
 import { RawTimeRange } from '@grafana/ui';
 import TableModel from 'app/core/table_model';
 import { LogsModel } from 'app/core/logs_model';
-import { DataSourceSelectItem } from 'app/types/datasources';
 
 export interface CompletionItem {
   /**
@@ -128,6 +127,19 @@ export interface QueryHintGetter {
   (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 {
   id: string;
   done: boolean;
@@ -142,6 +154,8 @@ export interface QueryTransaction {
   scanning?: boolean;
 }
 
+export type RangeScanner = () => RawTimeRange;
+
 export interface TextMatch {
   text: string;
   start: number;
@@ -153,18 +167,11 @@ 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;

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

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

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů