From 2be2deddb86d73fccb858d2ef379f60cac000b0c Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 10 Jan 2019 14:24:31 +0100 Subject: [PATCH] WIP Explore redux migration --- public/app/core/utils/explore.ts | 89 +- public/app/features/explore/Explore.tsx | 928 ++++-------------- public/app/features/explore/state/actions.ts | 694 +++++++++++++ public/app/features/explore/state/reducers.ts | 412 ++++++++ public/app/store/configureStore.ts | 2 + public/app/types/explore.ts | 23 +- public/app/types/index.ts | 2 + 7 files changed, 1413 insertions(+), 737 deletions(-) create mode 100644 public/app/features/explore/state/actions.ts create mode 100644 public/app/features/explore/state/reducers.ts diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index f3273ffa16d..871a020ccc2 100644 --- a/public/app/core/utils/explore.ts +++ b/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() }]; } /** diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index d4d645950c1..64e9c66ece5 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -1,35 +1,38 @@ import React from 'react'; import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; import _ from 'lodash'; +import { withSize } from 'react-sizeme'; +import { RawTimeRange, TimeRange } from '@grafana/ui'; -import { DataSource } from 'app/types/datasources'; -import { - ExploreState, - ExploreUrlState, - QueryTransaction, - ResultType, - QueryHintGetter, - QueryHint, -} from 'app/types/explore'; -import { TimeRange } from '@grafana/ui'; +import { DataSourceSelectItem } from 'app/types/datasources'; +import { ExploreUrlState, HistoryItem, QueryTransaction, RangeScanner } from 'app/types/explore'; import { DataQuery } from 'app/types/series'; import store from 'app/core/store'; -import { - DEFAULT_RANGE, - calculateResultsFromQueryTransactions, - ensureQueries, - getIntervals, - generateKey, - generateQueryKeys, - hasNonEmptyQuery, - makeTimeSeriesList, - updateHistory, -} from 'app/core/utils/explore'; +import { LAST_USED_DATASOURCE_KEY, ensureQueries } from 'app/core/utils/explore'; import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; -import TableModel from 'app/core/table_model'; -import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; import { Emitter } from 'app/core/utils/emitter'; -import * as dateMath from 'app/core/utils/datemath'; + +import { + addQueryRow, + changeDatasource, + changeQuery, + changeSize, + changeTime, + clickClear, + clickExample, + clickGraphButton, + clickLogsButton, + clickTableButton, + highlightLogsExpression, + initializeExplore, + modifyQueries, + removeQueryRow, + runQueries, + scanStart, + scanStop, +} from './state/actions'; +import { ExploreState } from './state/reducers'; import Panel from './Panel'; import QueryRows from './QueryRows'; @@ -39,17 +42,57 @@ import Table from './Table'; import ErrorBoundary from './ErrorBoundary'; import { Alert } from './Error'; import TimePicker, { parseTime } from './TimePicker'; - -const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource'; +import { LogsModel } from 'app/core/logs_model'; +import TableModel from 'app/core/table_model'; interface ExploreProps { - datasourceSrv: DatasourceSrv; + StartPage?: any; + addQueryRow: typeof addQueryRow; + changeDatasource: typeof changeDatasource; + changeQuery: typeof changeQuery; + changeTime: typeof changeTime; + clickClear: typeof clickClear; + clickExample: typeof clickExample; + clickGraphButton: typeof clickGraphButton; + clickLogsButton: typeof clickLogsButton; + clickTableButton: typeof clickTableButton; + datasourceError: string; + datasourceInstance: any; + datasourceLoading: boolean | null; + datasourceMissing: boolean; + exploreDatasources: DataSourceSelectItem[]; + graphResult?: any[]; + highlightLogsExpression: typeof highlightLogsExpression; + history: HistoryItem[]; + initialDatasource?: string; + initialQueries: DataQuery[]; + initializeExplore: typeof initializeExplore; + logsHighlighterExpressions?: string[]; + logsResult?: LogsModel; + modifyQueries: typeof modifyQueries; onChangeSplit: (split: boolean, state?: ExploreState) => void; onSaveState: (key: string, state: ExploreState) => void; position: string; + queryTransactions: QueryTransaction[]; + removeQueryRow: typeof removeQueryRow; + range: RawTimeRange; + runQueries: typeof runQueries; + scanner?: RangeScanner; + scanning?: boolean; + scanRange?: RawTimeRange; + scanStart: typeof scanStart; + scanStop: typeof scanStop; split: boolean; splitState?: ExploreState; stateKey: string; + showingGraph: boolean; + showingLogs: boolean; + showingStartPage?: boolean; + showingTable: boolean; + supportsGraph: boolean | null; + supportsLogs: boolean | null; + supportsTable: boolean | null; + tableResult?: TableModel; urlState: ExploreUrlState; } @@ -89,23 +132,9 @@ interface ExploreProps { * The result viewers determine some of the query options sent to the datasource, e.g., * `format`, to indicate eventual transformations by the datasources' result transformers. */ -export class Explore extends React.PureComponent { +export class Explore extends React.PureComponent { el: any; exploreEvents: Emitter; - /** - * Set via URL or local storage - */ - initialDatasource: string; - /** - * Current query expressions of the rows including their modifications, used for running queries. - * Not kept in component state to prevent edit-render roundtrips. - */ - modifiedQueries: DataQuery[]; - /** - * Local ID cache to compare requested vs selected datasource - */ - requestedDatasourceId: string; - scanTimer: NodeJS.Timer; /** * Timepicker to control scanning */ @@ -113,166 +142,22 @@ export class Explore extends React.PureComponent { constructor(props) { super(props); - const splitState: ExploreState = props.splitState; - let initialQueries: DataQuery[]; - if (splitState) { - // Split state overrides everything - this.state = splitState; - initialQueries = splitState.initialQueries; - } else { - const { datasource, queries, range } = props.urlState as ExploreUrlState; - const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY); - initialQueries = ensureQueries(queries); - const initialRange = { from: parseTime(range.from), to: parseTime(range.to) } || { ...DEFAULT_RANGE }; - // Millies step for helper bar charts - const initialGraphInterval = 15 * 1000; - this.state = { - datasource: null, - datasourceError: null, - datasourceLoading: null, - datasourceMissing: false, - exploreDatasources: [], - graphInterval: initialGraphInterval, - graphResult: [], - initialDatasource, - initialQueries, - history: [], - logsResult: null, - queryTransactions: [], - range: initialRange, - scanning: false, - showingGraph: true, - showingLogs: true, - showingStartPage: false, - showingTable: true, - supportsGraph: null, - supportsLogs: null, - supportsTable: null, - tableResult: new TableModel(), - }; - } - this.modifiedQueries = initialQueries.slice(); this.exploreEvents = new Emitter(); this.timepickerRef = React.createRef(); } async componentDidMount() { - const { datasourceSrv } = this.props; - const { initialDatasource } = this.state; - if (!datasourceSrv) { - throw new Error('No datasource service passed as props.'); - } - - const datasources = datasourceSrv.getExternal(); - const exploreDatasources = datasources.map(ds => ({ - value: ds.name, - name: ds.name, - meta: ds.meta, - })); - - if (datasources.length > 0) { - this.setState({ datasourceLoading: true, exploreDatasources }); - // Priority for datasource preselection: URL, localstorage, default datasource - let datasource; - if (initialDatasource) { - datasource = await datasourceSrv.get(initialDatasource); - } else { - datasource = await datasourceSrv.get(); - } - await this.setDatasource(datasource); - } else { - this.setState({ datasourceMissing: true }); - } + // Load URL state and parse range + const { datasource, queries, range } = this.props.urlState as ExploreUrlState; + const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY); + const initialQueries: DataQuery[] = ensureQueries(queries); + const initialRange = { from: parseTime(range.from), to: parseTime(range.to) }; + const width = this.el ? this.el.offsetWidth : 0; + this.props.initializeExplore(initialDatasource, initialQueries, initialRange, width, this.exploreEvents); } componentWillUnmount() { this.exploreEvents.removeAllListeners(); - clearTimeout(this.scanTimer); - } - - async setDatasource(datasource: any, origin?: DataSource) { - const { initialQueries, range } = this.state; - - const supportsGraph = datasource.meta.metrics; - const supportsLogs = datasource.meta.logs; - const supportsTable = datasource.meta.tables; - const datasourceId = datasource.meta.id; - let datasourceError = null; - - // Keep ID to track selection - this.requestedDatasourceId = datasourceId; - - try { - const testResult = await datasource.testDatasource(); - datasourceError = testResult.status === 'success' ? null : testResult.message; - } catch (error) { - datasourceError = (error && error.statusText) || 'Network error'; - } - - if (datasourceId !== this.requestedDatasourceId) { - // User already changed datasource again, discard results - return; - } - - const historyKey = `grafana.explore.history.${datasourceId}`; - const history = store.getObject(historyKey, []); - - if (datasource.init) { - datasource.init(); - } - - // Check if queries can be imported from previously selected datasource - let modifiedQueries = this.modifiedQueries; - if (origin) { - if (origin.meta.id === datasource.meta.id) { - // Keep same queries if same type of datasource - modifiedQueries = [...this.modifiedQueries]; - } else if (datasource.importQueries) { - // Datasource-specific importers - modifiedQueries = await datasource.importQueries(this.modifiedQueries, origin.meta); - } else { - // Default is blank queries - modifiedQueries = ensureQueries(); - } - } - - // Reset edit state with new queries - const nextQueries = initialQueries.map((q, i) => ({ - ...modifiedQueries[i], - ...generateQueryKeys(i), - })); - this.modifiedQueries = modifiedQueries; - - // Custom components - const StartPage = datasource.pluginExports.ExploreStartPage; - - // Calculate graph bucketing interval - const graphInterval = getIntervals(range, datasource, this.el ? this.el.offsetWidth : 0).intervalMs; - - this.setState( - { - StartPage, - datasource, - datasourceError, - graphInterval, - history, - supportsGraph, - supportsLogs, - supportsTable, - datasourceLoading: false, - initialDatasource: datasource.name, - initialQueries: nextQueries, - logsHighlighterExpressions: undefined, - showingStartPage: Boolean(StartPage), - }, - () => { - if (datasourceError === null) { - // Save last-used datasource - store.set(LAST_USED_DATASOURCE_KEY, datasource.name); - this.onSubmit(); - } - } - ); } getRef = el => { @@ -280,106 +165,32 @@ export class Explore extends React.PureComponent { }; onAddQueryRow = index => { - // Local cache - this.modifiedQueries[index + 1] = { ...generateQueryKeys(index + 1) }; - - this.setState(state => { - const { initialQueries, queryTransactions } = state; - - const nextQueries = [ - ...initialQueries.slice(0, index + 1), - { ...this.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 { - initialQueries: nextQueries, - logsHighlighterExpressions: undefined, - queryTransactions: nextQueryTransactions, - }; - }); + this.props.addQueryRow(index); }; onChangeDatasource = async option => { - const origin = this.state.datasource; - this.setState({ - datasource: null, - datasourceError: null, - datasourceLoading: true, - queryTransactions: [], - }); - const datasourceName = option.value; - const datasource = await this.props.datasourceSrv.get(datasourceName); - this.setDatasource(datasource as any, origin); + this.props.changeDatasource(option.value); }; - onChangeQuery = (value: DataQuery, index: number, override?: boolean) => { - // Null value means reset - if (value === null) { - value = { ...generateQueryKeys(index) }; - } + onChangeQuery = (query: DataQuery, index: number, override?: boolean) => { + const { changeQuery, datasourceInstance } = this.props; - // Keep current value in local cache - this.modifiedQueries[index] = value; - - if (override) { - this.setState(state => { - // Replace query row by injecting new key - const { initialQueries, queryTransactions } = state; - const query: DataQuery = { - ...value, - ...generateQueryKeys(index), - }; - const nextQueries = [...initialQueries]; - nextQueries[index] = query; - this.modifiedQueries = [...nextQueries]; - - // Discard ongoing transaction related to row query - const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); - - return { - initialQueries: nextQueries, - queryTransactions: nextQueryTransactions, - }; - }, this.onSubmit); - } else if (this.state.datasource.getHighlighterExpression && this.modifiedQueries.length === 1) { - // Live preview of log search matches. Can only work on single row query for now - this.updateLogsHighlights(value); + changeQuery(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); } }; - onChangeTime = (nextRange: TimeRange, scanning?: boolean) => { - const range: TimeRange = { - ...nextRange, - }; - if (this.state.scanning && !scanning) { + onChangeTime = (range: TimeRange, changedByScanner?: boolean) => { + if (this.props.scanning && !changedByScanner) { this.onStopScanning(); } - this.setState({ range, scanning }, () => this.onSubmit()); + this.props.changeTime(range); }; onClickClear = () => { - this.onStopScanning(); - this.modifiedQueries = ensureQueries(); - this.setState( - prevState => ({ - initialQueries: [...this.modifiedQueries], - queryTransactions: [], - showingStartPage: Boolean(prevState.StartPage), - }), - this.saveState - ); + this.props.clickClear(); }; onClickCloseSplit = () => { @@ -390,82 +201,28 @@ export class Explore extends React.PureComponent { }; onClickGraphButton = () => { - this.setState( - state => { - const showingGraph = !state.showingGraph; - let nextQueryTransactions = state.queryTransactions; - if (!showingGraph) { - // Discard transactions related to Graph query - nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Graph'); - } - return { queryTransactions: nextQueryTransactions, showingGraph }; - }, - () => { - if (this.state.showingGraph) { - this.onSubmit(); - } - } - ); + this.props.clickGraphButton(); }; onClickLogsButton = () => { - this.setState( - state => { - const showingLogs = !state.showingLogs; - let nextQueryTransactions = state.queryTransactions; - if (!showingLogs) { - // Discard transactions related to Logs query - nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Logs'); - } - return { queryTransactions: nextQueryTransactions, showingLogs }; - }, - () => { - if (this.state.showingLogs) { - this.onSubmit(); - } - } - ); + this.props.clickLogsButton(); }; // Use this in help pages to set page to a single query onClickExample = (query: DataQuery) => { - const nextQueries = [{ ...query, ...generateQueryKeys() }]; - this.modifiedQueries = [...nextQueries]; - this.setState({ initialQueries: nextQueries }, this.onSubmit); + this.props.clickExample(query); }; onClickSplit = () => { const { onChangeSplit } = this.props; if (onChangeSplit) { - const state = this.cloneState(); - onChangeSplit(true, state); + // const state = this.cloneState(); + // onChangeSplit(true, state); } }; onClickTableButton = () => { - this.setState( - state => { - const showingTable = !state.showingTable; - if (showingTable) { - return { showingTable, queryTransactions: state.queryTransactions }; - } - - // Toggle off needs discarding of table queries - const nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table'); - const results = calculateResultsFromQueryTransactions( - nextQueryTransactions, - state.datasource, - state.graphInterval - ); - - return { ...results, queryTransactions: nextQueryTransactions, showingTable }; - }, - () => { - if (this.state.showingTable) { - this.onSubmit(); - } - } - ); + this.props.clickTableButton(); }; onClickLabel = (key: string, value: string) => { @@ -473,404 +230,62 @@ export class Explore extends React.PureComponent { }; onModifyQueries = (action, index?: number) => { - const { datasource } = this.state; - if (datasource && datasource.modifyQuery) { - const preventSubmit = action.preventSubmit; - this.setState( - state => { - const { initialQueries, queryTransactions } = state; - let nextQueries: DataQuery[]; - let nextQueryTransactions; - if (index === undefined) { - // Modify all queries - nextQueries = initialQueries.map((query, i) => ({ - ...datasource.modifyQuery(this.modifiedQueries[i], action), - ...generateQueryKeys(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 - ? { - ...datasource.modifyQuery(this.modifiedQueries[i], action), - ...generateQueryKeys(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 !== action); - } - return qt; - }) - // Preserve previous row query transaction to keep results visible if next query is incomplete - .filter(qt => preventSubmit || qt.rowIndex !== index); - } - this.modifiedQueries = [...nextQueries]; - return { - initialQueries: nextQueries, - queryTransactions: nextQueryTransactions, - }; - }, - // Accepting certain fixes do not result in a well-formed query which should not be submitted - !preventSubmit ? () => this.onSubmit() : null - ); + const { datasourceInstance } = this.props; + if (datasourceInstance && datasourceInstance.modifyQuery) { + const modifier = (queries: DataQuery, action: any) => datasourceInstance.modifyQuery(queries, action); + this.props.modifyQueries(action, index, modifier); } }; onRemoveQueryRow = index => { - // Remove from local cache - this.modifiedQueries = [...this.modifiedQueries.slice(0, index), ...this.modifiedQueries.slice(index + 1)]; - - this.setState( - state => { - const { initialQueries, queryTransactions } = state; - if (initialQueries.length <= 1) { - return null; - } - // Remove row from react 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, - state.datasource, - state.graphInterval - ); - - return { - ...results, - initialQueries: nextQueries, - logsHighlighterExpressions: undefined, - queryTransactions: nextQueryTransactions, - }; - }, - () => this.onSubmit() - ); + this.props.removeQueryRow(index); }; onStartScanning = () => { - this.setState({ scanning: true }, this.scanPreviousRange); + // Scanner will trigger a query + const scanner = this.scanPreviousRange; + this.props.scanStart(scanner); }; - scanPreviousRange = () => { - const scanRange = this.timepickerRef.current.move(-1, true); - this.setState({ scanRange }); + scanPreviousRange = (): RawTimeRange => { + // Calling move() on the timepicker will trigger this.onChangeTime() + return this.timepickerRef.current.move(-1, true); }; onStopScanning = () => { - clearTimeout(this.scanTimer); - this.setState(state => { - const { queryTransactions } = state; - const nextQueryTransactions = queryTransactions.filter(qt => qt.scanning && !qt.done); - return { queryTransactions: nextQueryTransactions, scanning: false, scanRange: undefined }; - }); + this.props.scanStop(); }; onSubmit = () => { - const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state; - // Keep table queries first since they need to return quickly - if (showingTable && supportsTable) { - this.runQueries( - 'Table', - { - format: 'table', - instant: true, - valueWithRefId: true, - }, - data => data[0] - ); - } - if (showingGraph && supportsGraph) { - this.runQueries( - 'Graph', - { - format: 'time_series', - instant: false, - }, - makeTimeSeriesList - ); - } - if (showingLogs && supportsLogs) { - this.runQueries('Logs', { format: 'logs' }); - } - this.saveState(); + this.props.runQueries(); }; - buildQueryOptions(query: DataQuery, queryOptions: { format: string; hinting?: boolean; instant?: boolean }) { - const { datasource, range } = this.state; - const { interval, intervalMs } = getIntervals(range, datasource, this.el.offsetWidth); - - 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}`; - - return { - 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 }, - }, - }; - } - - startQueryTransaction(query: DataQuery, rowIndex: number, resultType: ResultType, options: any): QueryTransaction { - const queryOptions = this.buildQueryOptions(query, options); - const transaction: QueryTransaction = { - query, - resultType, - rowIndex, - id: generateKey(), // reusing for unique ID - done: false, - latency: 0, - options: queryOptions, - scanning: this.state.scanning, - }; - - // Using updater style because we might be modifying queryTransactions in quick succession - this.setState(state => { - const { queryTransactions } = state; - // Discarding existing transactions of same type - const remainingTransactions = queryTransactions.filter( - qt => !(qt.resultType === resultType && qt.rowIndex === rowIndex) - ); - - // Append new transaction - const nextQueryTransactions = [...remainingTransactions, transaction]; - - const results = calculateResultsFromQueryTransactions( - nextQueryTransactions, - state.datasource, - state.graphInterval - ); - - return { - ...results, - queryTransactions: nextQueryTransactions, - showingStartPage: false, - graphInterval: queryOptions.intervalMs, - }; - }); - - return transaction; - } - - completeQueryTransaction( - transactionId: string, - result: any, - latency: number, - queries: DataQuery[], - datasourceId: string - ) { - const { datasource } = this.state; - if (datasource.meta.id !== datasourceId) { - // Navigated away, queries did not matter - return; + updateLogsHighlights = _.debounce((value: DataQuery) => { + const { datasourceInstance } = this.props; + if (datasourceInstance.getHighlighterExpression) { + const expressions = [datasourceInstance.getHighlighterExpression(value)]; + this.props.highlightLogsExpression(expressions); } - - this.setState(state => { - const { history, queryTransactions } = state; - let { scanning } = state; - - // Transaction might have been discarded - const transaction = queryTransactions.find(qt => qt.id === transactionId); - if (!transaction) { - return null; - } - - // Get query hints - let hints: QueryHint[]; - if (datasource.getQueryHints as QueryHintGetter) { - hints = datasource.getQueryHints(transaction.query, result); - } - - // Mark transactions as complete - const nextQueryTransactions = queryTransactions.map(qt => { - if (qt.id === transactionId) { - return { - ...qt, - hints, - latency, - result, - done: true, - }; - } - return qt; - }); - - const results = calculateResultsFromQueryTransactions( - nextQueryTransactions, - state.datasource, - state.graphInterval - ); - - const nextHistory = updateHistory(history, datasourceId, queries); - - // 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) { - this.scanTimer = setTimeout(this.scanPreviousRange, 1000); - } - } else { - // We can stop scanning if we have a result - scanning = false; - } - } - - return { - ...results, - scanning, - history: nextHistory, - queryTransactions: nextQueryTransactions, - }; - }); - } - - failQueryTransaction(transactionId: string, response: any, datasourceId: string) { - const { datasource } = this.state; - if (datasource.meta.id !== datasourceId || response.cancelled) { - // Navigated away, queries did not matter - return; - } - - console.error(response); - - let error: string | JSX.Element; - if (response.data) { - if (typeof response.data === 'string') { - error = response.data; - } else if (response.data.error) { - error = response.data.error; - if (response.data.response) { - error = ( - <> - {response.data.error} -
{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.'; - } - - this.setState(state => { - // Transaction might have been discarded - if (!state.queryTransactions.find(qt => qt.id === transactionId)) { - return null; - } - - // Mark transactions as complete - const nextQueryTransactions = state.queryTransactions.map(qt => { - if (qt.id === transactionId) { - return { - ...qt, - error, - done: true, - }; - } - return qt; - }); - - return { - queryTransactions: nextQueryTransactions, - }; - }); - } - - async runQueries(resultType: ResultType, queryOptions: any, resultGetter?: any) { - const queries = [...this.modifiedQueries]; - if (!hasNonEmptyQuery(queries)) { - this.setState({ - queryTransactions: [], - }); - return; - } - const { datasource } = this.state; - const datasourceId = datasource.meta.id; - // Run all queries concurrentlyso - queries.forEach(async (query, rowIndex) => { - const transaction = this.startQueryTransaction(query, rowIndex, resultType, queryOptions); - try { - const now = Date.now(); - const res = await datasource.query(transaction.options); - this.exploreEvents.emit('data-received', res.data || []); - const latency = Date.now() - now; - const results = resultGetter ? resultGetter(res.data) : res.data; - this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); - } catch (response) { - this.exploreEvents.emit('data-error', response); - this.failQueryTransaction(transaction.id, response, datasourceId); - } - }); - } - - updateLogsHighlights = _.debounce((value: DataQuery, index: number) => { - this.setState(state => { - const { datasource } = state; - if (datasource.getHighlighterExpression) { - const logsHighlighterExpressions = [state.datasource.getHighlighterExpression(value)]; - return { logsHighlighterExpressions }; - } - return null; - }); }, 500); - cloneState(): ExploreState { - // Copy state, but copy queries including modifications - return { - ...this.state, - queryTransactions: [], - initialQueries: [...this.modifiedQueries], - }; - } + // cloneState(): ExploreState { + // // Copy state, but copy queries including modifications + // return { + // ...this.state, + // queryTransactions: [], + // initialQueries: [...this.modifiedQueries], + // }; + // } - saveState = () => { - const { stateKey, onSaveState } = this.props; - onSaveState(stateKey, this.cloneState()); - }; + // saveState = () => { + // const { stateKey, onSaveState } = this.props; + // onSaveState(stateKey, this.cloneState()); + // }; render() { - const { position, split } = this.props; const { StartPage, - datasource, + datasourceInstance, datasourceError, datasourceLoading, datasourceMissing, @@ -881,6 +296,7 @@ export class Explore extends React.PureComponent { logsHighlighterExpressions, logsResult, queryTransactions, + position, range, scanning, scanRange, @@ -888,14 +304,17 @@ export class Explore extends React.PureComponent { showingLogs, showingStartPage, showingTable, + split, supportsGraph, supportsLogs, supportsTable, tableResult, - } = this.state; + } = this.props; const graphHeight = showingGraph && showingTable ? '200px' : '400px'; const exploreClass = split ? 'explore explore-split' : 'explore'; - const selectedDatasource = datasource ? exploreDatasources.find(d => d.name === datasource.name) : undefined; + const selectedDatasource = datasourceInstance + ? exploreDatasources.find(d => d.name === datasourceInstance.name) + : undefined; const graphLoading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done); const tableLoading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done); const logsLoading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done); @@ -959,10 +378,10 @@ export class Explore extends React.PureComponent { )} - {datasource && !datasourceError ? ( + {datasourceInstance && !datasourceError ? (
{ } } -export default hot(module)(Explore); +function mapStateToProps({ explore }) { + const { + StartPage, + datasourceError, + datasourceInstance, + datasourceLoading, + datasourceMissing, + exploreDatasources, + graphResult, + initialDatasource, + initialQueries, + history, + logsHighlighterExpressions, + logsResult, + queryTransactions, + range, + scanning, + scanRange, + showingGraph, + showingLogs, + showingStartPage, + showingTable, + supportsGraph, + supportsLogs, + supportsTable, + tableResult, + } = explore as ExploreState; + return { + StartPage, + datasourceError, + datasourceInstance, + datasourceLoading, + datasourceMissing, + exploreDatasources, + graphResult, + initialDatasource, + initialQueries, + history, + logsHighlighterExpressions, + logsResult, + queryTransactions, + range, + scanning, + scanRange, + showingGraph, + showingLogs, + showingStartPage, + showingTable, + supportsGraph, + supportsLogs, + supportsTable, + tableResult, + }; +} + +const mapDispatchToProps = { + addQueryRow, + changeDatasource, + changeQuery, + changeTime, + clickClear, + clickExample, + clickGraphButton, + clickLogsButton, + clickTableButton, + highlightLogsExpression, + initializeExplore, + modifyQueries, + onSize: changeSize, // used by withSize HOC + removeQueryRow, + runQueries, + scanStart, + scanStop, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(withSize()(Explore))); diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts new file mode 100644 index 00000000000..d70a458059e --- /dev/null +++ b/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 = ThunkAction; + +export function addQueryRow(index: number): AddQueryRowAction { + const query = generateEmptyQuery(index + 1); + return { type: ActionTypes.AddQueryRow, index, query }; +} + +export function changeDatasource(datasource: string): ThunkResult { + return async dispatch => { + const instance = await getDatasourceSrv().get(datasource); + dispatch(loadDatasource(instance)); + }; +} + +export function changeQuery(query: DataQuery, index: number, override: boolean): ThunkResult { + 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 { + return dispatch => { + dispatch({ type: ActionTypes.ChangeTime, range }); + dispatch(runQueries()); + }; +} + +export function clickExample(rawQuery: DataQuery): ThunkResult { + return dispatch => { + const query = { ...rawQuery, ...generateEmptyQuery() }; + dispatch({ + type: ActionTypes.ClickExample, + query, + }); + dispatch(runQueries()); + }; +} + +export function clickClear(): ThunkResult { + return dispatch => { + dispatch(scanStop()); + dispatch({ type: ActionTypes.ClickClear }); + // TODO save state + }; +} + +export function clickGraphButton(): ThunkResult { + return (dispatch, getState) => { + dispatch({ type: ActionTypes.ClickGraphButton }); + if (getState().explore.showingGraph) { + dispatch(runQueries()); + } + }; +} + +export function clickLogsButton(): ThunkResult { + return (dispatch, getState) => { + dispatch({ type: ActionTypes.ClickLogsButton }); + if (getState().explore.showingLogs) { + dispatch(runQueries()); + } + }; +} + +export function clickTableButton(): ThunkResult { + 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 { + 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 { + 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 { + return dispatch => { + dispatch({ type: ActionTypes.ModifyQueries, modification, index, modifier }); + if (!modification.preventSubmit) { + dispatch(runQueries()); + } + }; +} + +export function queryTransactionFailure(transactionId: string, response: any, datasourceId: string): ThunkResult { + 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 { + 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 { + 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 { + return dispatch => { + dispatch({ type: ActionTypes.ScanStart, scanner }); + const range = scanner(); + dispatch({ type: ActionTypes.ScanRange, range }); + }; +} + +export function scanStop(): ScanStopAction { + return { type: ActionTypes.ScanStop }; +} diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts new file mode 100644 index 00000000000..b49d54405d1 --- /dev/null +++ b/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, +}; diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index 943aff80a70..570a387cd74 100644 --- a/public/app/store/configureStore.ts +++ b/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, diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index c2c59d35f5b..c64ce6133cf 100644 --- a/public/app/types/explore.ts +++ b/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; diff --git a/public/app/types/index.ts b/public/app/types/index.ts index 72da1c76ea8..018c4c51d3d 100644 --- a/public/app/types/index.ts +++ b/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;