diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index a3b08516d16..32135eab90a 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -6,26 +6,13 @@ import { clearHistory, hasNonEmptyQuery, } from './explore'; -import { ExploreState } from 'app/types/explore'; +import { ExploreUrlState } from 'app/types/explore'; import store from 'app/core/store'; -const DEFAULT_EXPLORE_STATE: ExploreState = { +const DEFAULT_EXPLORE_STATE: ExploreUrlState = { datasource: null, - datasourceError: null, - datasourceLoading: null, - datasourceMissing: false, - exploreDatasources: [], - graphInterval: 1000, - history: [], - initialQueries: [], - queryTransactions: [], + queries: [], range: DEFAULT_RANGE, - showingGraph: true, - showingLogs: true, - showingTable: true, - supportsGraph: null, - supportsLogs: null, - supportsTable: null, }; describe('state functions', () => { @@ -68,21 +55,19 @@ describe('state functions', () => { it('returns url parameter value for a state object', () => { const state = { ...DEFAULT_EXPLORE_STATE, - initialDatasource: 'foo', + datasource: 'foo', + queries: [ + { + expr: 'metric{test="a/b"}', + }, + { + expr: 'super{foo="x/z"}', + }, + ], range: { from: 'now-5h', to: 'now', }, - initialQueries: [ - { - refId: '1', - expr: 'metric{test="a/b"}', - }, - { - refId: '2', - expr: 'super{foo="x/z"}', - }, - ], }; expect(serializeStateToUrlParam(state)).toBe( '{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' + @@ -93,21 +78,19 @@ describe('state functions', () => { it('returns url parameter value for a state object', () => { const state = { ...DEFAULT_EXPLORE_STATE, - initialDatasource: 'foo', + datasource: 'foo', + queries: [ + { + expr: 'metric{test="a/b"}', + }, + { + expr: 'super{foo="x/z"}', + }, + ], range: { from: 'now-5h', to: 'now', }, - initialQueries: [ - { - refId: '1', - expr: 'metric{test="a/b"}', - }, - { - refId: '2', - expr: 'super{foo="x/z"}', - }, - ], }; expect(serializeStateToUrlParam(state, true)).toBe( '["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"}]' @@ -119,35 +102,24 @@ describe('state functions', () => { it('can parse the serialized state into the original state', () => { const state = { ...DEFAULT_EXPLORE_STATE, - initialDatasource: 'foo', + datasource: 'foo', + queries: [ + { + expr: 'metric{test="a/b"}', + }, + { + expr: 'super{foo="x/z"}', + }, + ], range: { from: 'now - 5h', to: 'now', }, - initialQueries: [ - { - refId: '1', - expr: 'metric{test="a/b"}', - }, - { - refId: '2', - expr: 'super{foo="x/z"}', - }, - ], }; const serialized = serializeStateToUrlParam(state); const parsed = parseUrlState(serialized); - // Account for datasource vs datasourceName - const { datasource, queries, ...rest } = parsed; - const resultState = { - ...rest, - datasource: DEFAULT_EXPLORE_STATE.datasource, - initialDatasource: datasource, - initialQueries: queries, - }; - - expect(state).toMatchObject(resultState); + expect(state).toMatchObject(parsed); }); }); }); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index f3273ffa16d..b05e38a4b33 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 { DataQuery, DataSourceApi } from 'app/types/series'; -import { RawTimeRange, IntervalValues } from '@grafana/ui'; +import { + ExploreUrlState, + HistoryItem, + QueryTransaction, + ResultType, + QueryIntervals, + QueryOptions, +} from 'app/types/explore'; +import { DataQuery } from 'app/types/series'; export const DEFAULT_RANGE = { 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,7 +86,63 @@ export async function getExploreUrl( return url; } -const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest; +export function buildQueryTransaction( + query: DataQuery, + rowIndex: number, + resultType: ResultType, + queryOptions: QueryOptions, + range: RawTimeRange, + queryIntervals: QueryIntervals, + scanning: boolean +): QueryTransaction { + const { interval, intervalMs } = queryIntervals; + + const configuredQueries = [ + { + ...query, + ...queryOptions, + }, + ]; + + // Clone range for query request + // const queryRange: RawTimeRange = { ...range }; + // const { from, to, raw } = this.timeSrv.timeRange(); + // Most datasource is using `panelId + query.refId` for cancellation logic. + // Using `format` here because it relates to the view panel that the request is for. + // However, some datasources don't use `panelId + query.refId`, but only `panelId`. + // Therefore panel id has to be unique. + const panelId = `${queryOptions.format}-${query.key}`; + + const options = { + interval, + intervalMs, + panelId, + targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key. + range: { + from: dateMath.parse(range.from, false), + to: dateMath.parse(range.to, true), + raw: range, + }, + rangeRaw: range, + scopedVars: { + __interval: { text: interval, value: interval }, + __interval_ms: { text: intervalMs, value: intervalMs }, + }, + }; + + return { + options, + query, + resultType, + rowIndex, + scanning, + id: generateKey(), // reusing for unique ID + done: false, + latency: 0, + }; +} + +export const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest; export function parseUrlState(initial: string | undefined): ExploreUrlState { if (initial) { @@ -103,12 +168,7 @@ 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 { if (compact) { return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]); } @@ -123,7 +183,7 @@ export function generateRefId(index = 0): string { return `${index + 1}`; } -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,20 +192,23 @@ 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() }]; } /** * A target is non-empty when it has keys (with non-empty values) other than refId and key. */ export function hasNonEmptyQuery(queries: DataQuery[]): boolean { - return queries.some( - query => - Object.keys(query) - .map(k => query[k]) - .filter(v => v).length > 2 + return ( + queries && + queries.some( + query => + Object.keys(query) + .map(k => query[k]) + .filter(v => v).length > 2 + ) ); } @@ -180,8 +243,8 @@ export function calculateResultsFromQueryTransactions( }; } -export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, resolution: number): IntervalValues { - if (!datasource || !resolution) { +export function getIntervals(range: RawTimeRange, lowLimit: string, resolution: number): IntervalValues { + if (!resolution) { return { interval: '1s', intervalMs: 1000 }; } @@ -190,7 +253,7 @@ export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, res to: parseDate(range.to, true), }; - return kbn.calculateInterval(absoluteRange, resolution, datasource.interval); + return kbn.calculateInterval(absoluteRange, resolution, lowLimit); } export function makeTimeSeriesList(dataList) { diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index d4d645950c1..a8acab50137 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -1,55 +1,75 @@ import React from 'react'; import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; import _ from 'lodash'; +import { AutoSizer } from 'react-virtualized'; +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 { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId } from 'app/types/explore'; import { DataQuery } from 'app/types/series'; +import { StoreState } from 'app/types'; 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, DEFAULT_RANGE } 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 Panel from './Panel'; -import QueryRows from './QueryRows'; -import Graph from './Graph'; -import Logs from './Logs'; -import Table from './Table'; -import ErrorBoundary from './ErrorBoundary'; +import { + changeDatasource, + changeSize, + changeTime, + clearQueries, + initializeExplore, + modifyQueries, + runQueries, + scanStart, + scanStop, + setQueries, + splitClose, + splitOpen, +} from './state/actions'; + import { Alert } from './Error'; +import ErrorBoundary from './ErrorBoundary'; +import GraphContainer from './GraphContainer'; +import LogsContainer from './LogsContainer'; +import QueryRows from './QueryRows'; +import TableContainer from './TableContainer'; import TimePicker, { parseTime } from './TimePicker'; -const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource'; - interface ExploreProps { - datasourceSrv: DatasourceSrv; - onChangeSplit: (split: boolean, state?: ExploreState) => void; - onSaveState: (key: string, state: ExploreState) => void; - position: string; + StartPage?: any; + changeDatasource: typeof changeDatasource; + changeSize: typeof changeSize; + changeTime: typeof changeTime; + clearQueries: typeof clearQueries; + datasourceError: string; + datasourceInstance: any; + datasourceLoading: boolean | null; + datasourceMissing: boolean; + exploreDatasources: DataSourceSelectItem[]; + exploreId: ExploreId; + initialDatasource?: string; + initialQueries: DataQuery[]; + initializeExplore: typeof initializeExplore; + initialized: boolean; + loading: boolean; + modifyQueries: typeof modifyQueries; + range: RawTimeRange; + runQueries: typeof runQueries; + scanner?: RangeScanner; + scanning?: boolean; + scanRange?: RawTimeRange; + scanStart: typeof scanStart; + scanStop: typeof scanStop; + setQueries: typeof setQueries; split: boolean; - splitState?: ExploreState; - stateKey: string; + splitClose: typeof splitClose; + splitOpen: typeof splitOpen; + showingStartPage?: boolean; + supportsGraph: boolean | null; + supportsLogs: boolean | null; + supportsTable: boolean | null; urlState: ExploreUrlState; } @@ -58,26 +78,14 @@ interface ExploreProps { * Once a datasource is selected it populates the query section at the top. * When queries are run, their results are being displayed in the main section. * The datasource determines what kind of query editor it brings, and what kind - * of results viewers it supports. + * of results viewers it supports. The state is managed entirely in Redux. * - * QUERY HANDLING + * SPLIT VIEW * - * TLDR: to not re-render Explore during edits, query editing is not "controlled" - * in a React sense: values need to be pushed down via `initialQueries`, while - * edits travel up via `this.modifiedQueries`. - * - * By default the query rows start without prior state: `initialQueries` will - * contain one empty DataQuery. While the user modifies the DataQuery, the - * modifications are being tracked in `this.modifiedQueries`, which need to be - * used whenever a query is sent to the datasource to reflect what the user sees - * on the screen. Query"react-popper": "^0.7.5", rows can be initialized or reset using `initialQueries`, - * by giving the respec"react-popper": "^0.7.5",tive row a new key. This wipes the old row and its state. - * This property is als"react-popper": "^0.7.5",o used to govern how many query rows there are (minimum 1). - * - * This flow makes sure that a query row can be arbitrarily complex without the - * fear of being wiped or re-initialized via props. The query row is free to keep - * its own state while the user edits or builds a query. Valid queries can be sent - * up to Explore via the `onChangeQuery` prop. + * Explore can have two Explore areas side-by-side. This is handled in `Wrapper.tsx`. + * Since there can be multiple Explores (e.g., left and right) each action needs + * the `exploreId` as first parameter so that the reducer knows which Explore state + * is affected. * * DATASOURCE REQUESTS * @@ -89,23 +97,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,359 +107,65 @@ 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 }); + const { exploreId, initialized, urlState } = this.props; + // Don't initialize on split, but need to initialize urlparameters when present + if (!initialized) { + // Load URL state and parse range + const { datasource, queries, range = DEFAULT_RANGE } = (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( + exploreId, + 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 => { this.el = el; }; - 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, - }; - }); - }; - 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(this.props.exploreId, option.value); }; - onChangeQuery = (value: DataQuery, index: number, override?: boolean) => { - // Null value means reset - if (value === null) { - value = { ...generateQueryKeys(index) }; - } - - // 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); - } - }; - - 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(this.props.exploreId, range); }; onClickClear = () => { - this.onStopScanning(); - this.modifiedQueries = ensureQueries(); - this.setState( - prevState => ({ - initialQueries: [...this.modifiedQueries], - queryTransactions: [], - showingStartPage: Boolean(prevState.StartPage), - }), - this.saveState - ); + this.props.clearQueries(this.props.exploreId); }; onClickCloseSplit = () => { - const { onChangeSplit } = this.props; - if (onChangeSplit) { - onChangeSplit(false); - } - }; - - 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(); - } - } - ); - }; - - 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.splitClose(); }; // 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.setQueries(this.props.exploreId, [query]); }; onClickSplit = () => { - const { onChangeSplit } = this.props; - if (onChangeSplit) { - 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.splitOpen(); }; onClickLabel = (key: string, value: string) => { @@ -473,438 +173,63 @@ 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, modification: any) => datasourceInstance.modifyQuery(queries, modification); + this.props.modifyQueries(this.props.exploreId, 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() - ); + onResize = (size: { height: number; width: number }) => { + this.props.changeSize(this.props.exploreId, size); }; onStartScanning = () => { - this.setState({ scanning: true }, this.scanPreviousRange); + // Scanner will trigger a query + const scanner = this.scanPreviousRange; + this.props.scanStart(this.props.exploreId, 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(this.props.exploreId); }; 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(); - }; - - 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; - } - - 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], - }; - } - - saveState = () => { - const { stateKey, onSaveState } = this.props; - onSaveState(stateKey, this.cloneState()); + this.props.runQueries(this.props.exploreId); }; render() { - const { position, split } = this.props; const { StartPage, - datasource, + datasourceInstance, datasourceError, datasourceLoading, datasourceMissing, exploreDatasources, - graphResult, - history, + exploreId, + loading, initialQueries, - logsHighlighterExpressions, - logsResult, - queryTransactions, range, - scanning, - scanRange, - showingGraph, - showingLogs, showingStartPage, - showingTable, + split, supportsGraph, supportsLogs, supportsTable, - tableResult, - } = this.state; - const graphHeight = showingGraph && showingTable ? '200px' : '400px'; + } = this.props; const exploreClass = split ? 'explore explore-split' : 'explore'; - const selectedDatasource = datasource ? exploreDatasources.find(d => d.name === datasource.name) : undefined; - const graphLoading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done); - const tableLoading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done); - const logsLoading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done); - const loading = queryTransactions.some(qt => !qt.done); + const selectedDatasource = datasourceInstance + ? exploreDatasources.find(d => d.name === datasourceInstance.name) + : undefined; return (
- {position === 'left' ? ( + {exploreId === 'left' ? ( ) : null}
- {position === 'left' && !split ? ( + {exploreId === 'left' && !split ? (
)} - {datasource && !datasourceError ? ( -
- -
- - {showingStartPage && } - {!showingStartPage && ( - <> - {supportsGraph && ( - - - - )} - {supportsTable && ( - - - - )} - {supportsLogs && ( - - - - )} - + {datasourceInstance && + !datasourceError && ( +
+ + + {({ width }) => ( +
+ + {showingStartPage && } + {!showingStartPage && ( + <> + {supportsGraph && } + {supportsTable && } + {supportsLogs && ( + + )} + + )} + +
)} - - -
- ) : null} + + + )} ); } } -export default hot(module)(Explore); +function mapStateToProps(state: StoreState, { exploreId }) { + const explore = state.explore; + const { split } = explore; + const item: ExploreItemState = explore[exploreId]; + const { + StartPage, + datasourceError, + datasourceInstance, + datasourceLoading, + datasourceMissing, + exploreDatasources, + initialDatasource, + initialQueries, + initialized, + queryTransactions, + range, + showingStartPage, + supportsGraph, + supportsLogs, + supportsTable, + } = item; + const loading = queryTransactions.some(qt => !qt.done); + return { + StartPage, + datasourceError, + datasourceInstance, + datasourceLoading, + datasourceMissing, + exploreDatasources, + initialDatasource, + initialQueries, + initialized, + loading, + queryTransactions, + range, + showingStartPage, + split, + supportsGraph, + supportsLogs, + supportsTable, + }; +} + +const mapDispatchToProps = { + changeDatasource, + changeSize, + changeTime, + clearQueries, + initializeExplore, + modifyQueries, + runQueries, + scanStart, + scanStop, + setQueries, + splitClose, + splitOpen, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Explore)); diff --git a/public/app/features/explore/GraphContainer.tsx b/public/app/features/explore/GraphContainer.tsx new file mode 100644 index 00000000000..e2610bcc781 --- /dev/null +++ b/public/app/features/explore/GraphContainer.tsx @@ -0,0 +1,61 @@ +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import { RawTimeRange, TimeRange } from '@grafana/ui'; + +import { ExploreId, ExploreItemState } from 'app/types/explore'; +import { StoreState } from 'app/types'; + +import { toggleGraph } from './state/actions'; +import Graph from './Graph'; +import Panel from './Panel'; + +interface GraphContainerProps { + onChangeTime: (range: TimeRange) => void; + exploreId: ExploreId; + graphResult?: any[]; + loading: boolean; + range: RawTimeRange; + showingGraph: boolean; + showingTable: boolean; + split: boolean; + toggleGraph: typeof toggleGraph; +} + +export class GraphContainer extends PureComponent { + onClickGraphButton = () => { + this.props.toggleGraph(this.props.exploreId); + }; + + render() { + const { exploreId, graphResult, loading, onChangeTime, showingGraph, showingTable, range, split } = this.props; + const graphHeight = showingGraph && showingTable ? '200px' : '400px'; + return ( + + + + ); + } +} + +function mapStateToProps(state: StoreState, { exploreId }) { + const explore = state.explore; + const { split } = explore; + const item: ExploreItemState = explore[exploreId]; + const { graphResult, queryTransactions, range, showingGraph, showingTable } = item; + const loading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done); + return { graphResult, loading, range, showingGraph, showingTable, split }; +} + +const mapDispatchToProps = { + toggleGraph, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(GraphContainer)); diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 1a384cf011d..d07b31e2ff1 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -241,9 +241,9 @@ function renderMetaItem(value: any, kind: LogsMetaKind) { interface LogsProps { data: LogsModel; + exploreId: string; highlighterExpressions: string[]; loading: boolean; - position: string; range?: RawTimeRange; scanning?: boolean; scanRange?: RawTimeRange; @@ -348,10 +348,10 @@ export default class Logs extends PureComponent { render() { const { data, + exploreId, highlighterExpressions, loading = false, onClickLabel, - position, range, scanning, scanRange, @@ -400,7 +400,7 @@ export default class Logs extends PureComponent { data={data.series} height="100px" range={range} - id={`explore-logs-graph-${position}`} + id={`explore-logs-graph-${exploreId}`} onChangeTime={this.props.onChangeTime} onToggleSeries={this.onToggleLogLevel} userOptions={graphOptions} diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx new file mode 100644 index 00000000000..e58cd2b5e95 --- /dev/null +++ b/public/app/features/explore/LogsContainer.tsx @@ -0,0 +1,91 @@ +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import { RawTimeRange, TimeRange } from '@grafana/ui'; + +import { ExploreId, ExploreItemState } from 'app/types/explore'; +import { LogsModel } from 'app/core/logs_model'; +import { StoreState } from 'app/types'; + +import { toggleLogs } from './state/actions'; +import Logs from './Logs'; +import Panel from './Panel'; + +interface LogsContainerProps { + exploreId: ExploreId; + loading: boolean; + logsHighlighterExpressions?: string[]; + logsResult?: LogsModel; + onChangeTime: (range: TimeRange) => void; + onClickLabel: (key: string, value: string) => void; + onStartScanning: () => void; + onStopScanning: () => void; + range: RawTimeRange; + scanning?: boolean; + scanRange?: RawTimeRange; + showingLogs: boolean; + toggleLogs: typeof toggleLogs; +} + +export class LogsContainer extends PureComponent { + onClickLogsButton = () => { + this.props.toggleLogs(this.props.exploreId); + }; + + render() { + const { + exploreId, + loading, + logsHighlighterExpressions, + logsResult, + onChangeTime, + onClickLabel, + onStartScanning, + onStopScanning, + range, + showingLogs, + scanning, + scanRange, + } = this.props; + return ( + + + + ); + } +} + +function mapStateToProps(state: StoreState, { exploreId }) { + const explore = state.explore; + const item: ExploreItemState = explore[exploreId]; + const { logsHighlighterExpressions, logsResult, queryTransactions, scanning, scanRange, showingLogs, range } = item; + const loading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done); + return { + loading, + logsHighlighterExpressions, + logsResult, + scanning, + scanRange, + showingLogs, + range, + }; +} + +const mapDispatchToProps = { + toggleLogs, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(LogsContainer)); diff --git a/public/app/features/explore/QueryEditor.tsx b/public/app/features/explore/QueryEditor.tsx index ce0a8a6e03e..dde674d3fcd 100644 --- a/public/app/features/explore/QueryEditor.tsx +++ b/public/app/features/explore/QueryEditor.tsx @@ -48,7 +48,7 @@ export default class QueryEditor extends PureComponent { getNextQueryLetter: x => '', }, hideEditorRowActions: true, - ...getIntervals(range, datasource, null), // Possible to get resolution? + ...getIntervals(range, (datasource || {}).interval, null), // Possible to get resolution? }, }; diff --git a/public/app/features/explore/QueryRow.tsx b/public/app/features/explore/QueryRow.tsx new file mode 100644 index 00000000000..b5b150b3ba8 --- /dev/null +++ b/public/app/features/explore/QueryRow.tsx @@ -0,0 +1,163 @@ +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import { RawTimeRange } from '@grafana/ui'; +import _ from 'lodash'; + +import { QueryTransaction, HistoryItem, QueryHint, ExploreItemState, ExploreId } from 'app/types/explore'; +import { Emitter } from 'app/core/utils/emitter'; +import { DataQuery, StoreState } from 'app/types'; + +// import DefaultQueryField from './QueryField'; +import QueryEditor from './QueryEditor'; +import QueryTransactionStatus from './QueryTransactionStatus'; +import { + addQueryRow, + changeQuery, + highlightLogsExpression, + modifyQueries, + removeQueryRow, + runQueries, +} from './state/actions'; + +function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint { + const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0); + if (transaction) { + return transaction.hints[0]; + } + return undefined; +} + +interface QueryRowProps { + addQueryRow: typeof addQueryRow; + changeQuery: typeof changeQuery; + className?: string; + exploreId: ExploreId; + datasourceInstance: any; + highlightLogsExpression: typeof highlightLogsExpression; + history: HistoryItem[]; + index: number; + initialQuery: DataQuery; + modifyQueries: typeof modifyQueries; + queryTransactions: QueryTransaction[]; + exploreEvents: Emitter; + range: RawTimeRange; + removeQueryRow: typeof removeQueryRow; + runQueries: typeof runQueries; +} + +export class QueryRow extends PureComponent { + onExecuteQuery = () => { + const { exploreId } = this.props; + this.props.runQueries(exploreId); + }; + + onChangeQuery = (query: DataQuery, override?: boolean) => { + const { datasourceInstance, exploreId, index } = this.props; + this.props.changeQuery(exploreId, query, index, override); + if (query && !override && datasourceInstance.getHighlighterExpression && index === 0) { + // Live preview of log search matches. Only use on first row for now + this.updateLogsHighlights(query); + } + }; + + onClickAddButton = () => { + const { exploreId, index } = this.props; + this.props.addQueryRow(exploreId, index); + }; + + onClickClearButton = () => { + this.onChangeQuery(null, true); + }; + + onClickHintFix = action => { + const { datasourceInstance, exploreId, index } = this.props; + if (datasourceInstance && datasourceInstance.modifyQuery) { + const modifier = (queries: DataQuery, action: any) => datasourceInstance.modifyQuery(queries, action); + this.props.modifyQueries(exploreId, action, index, modifier); + } + }; + + onClickRemoveButton = () => { + const { exploreId, index } = this.props; + this.props.removeQueryRow(exploreId, index); + }; + + updateLogsHighlights = _.debounce((value: DataQuery) => { + const { datasourceInstance } = this.props; + if (datasourceInstance.getHighlighterExpression) { + const expressions = [datasourceInstance.getHighlighterExpression(value)]; + this.props.highlightLogsExpression(this.props.exploreId, expressions); + } + }, 500); + + render() { + const { datasourceInstance, history, index, initialQuery, queryTransactions, exploreEvents, range } = this.props; + const transactions = queryTransactions.filter(t => t.rowIndex === index); + const transactionWithError = transactions.find(t => t.error !== undefined); + const hint = getFirstHintFromTransactions(transactions); + const queryError = transactionWithError ? transactionWithError.error : null; + const QueryField = datasourceInstance.pluginExports.ExploreQueryField; + return ( +
+
+ +
+
+ {QueryField ? ( + + ) : ( + + )} +
+
+ + + +
+
+ ); + } +} + +function mapStateToProps(state: StoreState, { exploreId, index }) { + const explore = state.explore; + const item: ExploreItemState = explore[exploreId]; + const { datasourceInstance, history, initialQueries, queryTransactions, range } = item; + const initialQuery = initialQueries[index]; + return { datasourceInstance, history, initialQuery, queryTransactions, range }; +} + +const mapDispatchToProps = { + addQueryRow, + changeQuery, + highlightLogsExpression, + modifyQueries, + removeQueryRow, + runQueries, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(QueryRow)); diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx index 4101475092b..01bd409f444 100644 --- a/public/app/features/explore/QueryRows.tsx +++ b/public/app/features/explore/QueryRows.tsx @@ -1,159 +1,25 @@ import React, { PureComponent } from 'react'; -import { QueryTransaction, HistoryItem, QueryHint } from 'app/types/explore'; import { Emitter } from 'app/core/utils/emitter'; +import { DataQuery } from 'app/types'; +import { ExploreId } from 'app/types/explore'; -// import DefaultQueryField from './QueryField'; -import QueryEditor from './QueryEditor'; -import QueryTransactionStatus from './QueryTransactionStatus'; -import { DataSource, DataQuery } from 'app/types'; -import { RawTimeRange } from '@grafana/ui'; +import QueryRow from './QueryRow'; -function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint { - const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0); - if (transaction) { - return transaction.hints[0]; - } - return undefined; -} - -interface QueryRowEventHandlers { - onAddQueryRow: (index: number) => void; - onChangeQuery: (value: DataQuery, index: number, override?: boolean) => void; - onClickHintFix: (action: object, index?: number) => void; - onExecuteQuery: () => void; - onRemoveQueryRow: (index: number) => void; -} - -interface QueryRowCommonProps { +interface QueryRowsProps { className?: string; - datasource: DataSource; - history: HistoryItem[]; - transactions: QueryTransaction[]; exploreEvents: Emitter; - range: RawTimeRange; + exploreId: ExploreId; + initialQueries: DataQuery[]; } - -type QueryRowProps = QueryRowCommonProps & - QueryRowEventHandlers & { - index: number; - initialQuery: DataQuery; - }; - -class QueryRow extends PureComponent { - onExecuteQuery = () => { - const { onExecuteQuery } = this.props; - onExecuteQuery(); - }; - - onChangeQuery = (value: DataQuery, override?: boolean) => { - const { index, onChangeQuery } = this.props; - if (onChangeQuery) { - onChangeQuery(value, index, override); - } - }; - - onClickAddButton = () => { - const { index, onAddQueryRow } = this.props; - if (onAddQueryRow) { - onAddQueryRow(index); - } - }; - - onClickClearButton = () => { - this.onChangeQuery(null, true); - }; - - onClickHintFix = action => { - const { index, onClickHintFix } = this.props; - if (onClickHintFix) { - onClickHintFix(action, index); - } - }; - - onClickRemoveButton = () => { - const { index, onRemoveQueryRow } = this.props; - if (onRemoveQueryRow) { - onRemoveQueryRow(index); - } - }; - - onPressEnter = () => { - const { onExecuteQuery } = this.props; - if (onExecuteQuery) { - onExecuteQuery(); - } - }; - - render() { - const { datasource, history, initialQuery, transactions, exploreEvents, range } = this.props; - const transactionWithError = transactions.find(t => t.error !== undefined); - const hint = getFirstHintFromTransactions(transactions); - const queryError = transactionWithError ? transactionWithError.error : null; - const QueryField = datasource.pluginExports.ExploreQueryField; - return ( -
-
- -
-
- {QueryField ? ( - - ) : ( - - )} -
-
- - - -
-
- ); - } -} - -type QueryRowsProps = QueryRowCommonProps & - QueryRowEventHandlers & { - initialQueries: DataQuery[]; - }; - export default class QueryRows extends PureComponent { render() { - const { className = '', initialQueries, transactions, ...handlers } = this.props; + const { className = '', exploreEvents, exploreId, initialQueries } = this.props; return (
{initialQueries.map((query, index) => ( - t.rowIndex === index)} - {...handlers} - /> + // TODO instead of relying on initialQueries, move to react key list in redux + ))}
); diff --git a/public/app/features/explore/TableContainer.tsx b/public/app/features/explore/TableContainer.tsx new file mode 100644 index 00000000000..1d00a441e14 --- /dev/null +++ b/public/app/features/explore/TableContainer.tsx @@ -0,0 +1,49 @@ +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; + +import { ExploreId, ExploreItemState } from 'app/types/explore'; +import { StoreState } from 'app/types'; + +import { toggleGraph } from './state/actions'; +import Table from './Table'; +import Panel from './Panel'; +import TableModel from 'app/core/table_model'; + +interface TableContainerProps { + exploreId: ExploreId; + loading: boolean; + onClickCell: (key: string, value: string) => void; + showingTable: boolean; + tableResult?: TableModel; + toggleGraph: typeof toggleGraph; +} + +export class TableContainer extends PureComponent { + onClickTableButton = () => { + this.props.toggleGraph(this.props.exploreId); + }; + + render() { + const { loading, onClickCell, showingTable, tableResult } = this.props; + return ( + +
+ + ); + } +} + +function mapStateToProps(state: StoreState, { exploreId }) { + const explore = state.explore; + const item: ExploreItemState = explore[exploreId]; + const { queryTransactions, showingTable, tableResult } = item; + const loading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done); + return { loading, showingTable, tableResult }; +} + +const mapDispatchToProps = { + toggleGraph, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(TableContainer)); diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx index de1eee4c662..7ea8f228af8 100644 --- a/public/app/features/explore/Wrapper.tsx +++ b/public/app/features/explore/Wrapper.tsx @@ -3,91 +3,56 @@ import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; import { updateLocation } from 'app/core/actions'; -import { serializeStateToUrlParam, parseUrlState } from 'app/core/utils/explore'; import { StoreState } from 'app/types'; -import { ExploreState } from 'app/types/explore'; +import { ExploreId, ExploreUrlState } from 'app/types/explore'; +import { parseUrlState } from 'app/core/utils/explore'; +import { initializeExploreSplit } from './state/actions'; import ErrorBoundary from './ErrorBoundary'; import Explore from './Explore'; interface WrapperProps { - backendSrv?: any; - datasourceSrv?: any; + initializeExploreSplit: typeof initializeExploreSplit; + split: boolean; updateLocation: typeof updateLocation; urlStates: { [key: string]: string }; } -interface WrapperState { - split: boolean; - splitState: ExploreState; -} - -const STATE_KEY_LEFT = 'state'; -const STATE_KEY_RIGHT = 'stateRight'; - -export class Wrapper extends Component { - urlStates: { [key: string]: string }; +export class Wrapper extends Component { + initialSplit: boolean; + urlStates: { [key: string]: ExploreUrlState }; constructor(props: WrapperProps) { super(props); - this.urlStates = props.urlStates; - this.state = { - split: Boolean(props.urlStates[STATE_KEY_RIGHT]), - splitState: undefined, - }; + this.urlStates = {}; + const { left, right } = props.urlStates; + if (props.urlStates.left) { + this.urlStates.leftState = parseUrlState(left); + } + if (props.urlStates.right) { + this.urlStates.rightState = parseUrlState(right); + this.initialSplit = true; + } } - onChangeSplit = (split: boolean, splitState: ExploreState) => { - this.setState({ split, splitState }); - // When closing split, remove URL state for split part - if (!split) { - delete this.urlStates[STATE_KEY_RIGHT]; - this.props.updateLocation({ - query: this.urlStates, - }); + componentDidMount() { + if (this.initialSplit) { + this.props.initializeExploreSplit(); } - }; - - onSaveState = (key: string, state: ExploreState) => { - const urlState = serializeStateToUrlParam(state, true); - this.urlStates[key] = urlState; - this.props.updateLocation({ - query: this.urlStates, - }); - }; + } render() { - const { datasourceSrv } = this.props; - // State overrides for props from first Explore - const { split, splitState } = this.state; - const urlStateLeft = parseUrlState(this.urlStates[STATE_KEY_LEFT]); - const urlStateRight = parseUrlState(this.urlStates[STATE_KEY_RIGHT]); + const { split } = this.props; + const { leftState, rightState } = this.urlStates; return (
- + {split && ( - + )}
@@ -95,11 +60,14 @@ export class Wrapper extends Component { } } -const mapStateToProps = (state: StoreState) => ({ - urlStates: state.location.query, -}); +const mapStateToProps = (state: StoreState) => { + const urlStates = state.location.query; + const { split } = state.explore; + return { split, urlStates }; +}; const mapDispatchToProps = { + initializeExploreSplit, updateLocation, }; diff --git a/public/app/features/explore/state/actionTypes.ts b/public/app/features/explore/state/actionTypes.ts new file mode 100644 index 00000000000..b267da4f2c1 --- /dev/null +++ b/public/app/features/explore/state/actionTypes.ts @@ -0,0 +1,302 @@ +import { RawTimeRange, TimeRange } from '@grafana/ui'; + +import { Emitter } from 'app/core/core'; +import { + ExploreId, + ExploreItemState, + HistoryItem, + RangeScanner, + ResultType, + QueryTransaction, +} from 'app/types/explore'; +import { DataSourceSelectItem } from 'app/types/datasources'; +import { DataQuery } from 'app/types'; + +export enum ActionTypes { + AddQueryRow = 'explore/ADD_QUERY_ROW', + ChangeDatasource = 'explore/CHANGE_DATASOURCE', + ChangeQuery = 'explore/CHANGE_QUERY', + ChangeSize = 'explore/CHANGE_SIZE', + ChangeTime = 'explore/CHANGE_TIME', + ClearQueries = 'explore/CLEAR_QUERIES', + HighlightLogsExpression = 'explore/HIGHLIGHT_LOGS_EXPRESSION', + InitializeExplore = 'explore/INITIALIZE_EXPLORE', + InitializeExploreSplit = 'explore/INITIALIZE_EXPLORE_SPLIT', + LoadDatasourceFailure = 'explore/LOAD_DATASOURCE_FAILURE', + LoadDatasourceMissing = 'explore/LOAD_DATASOURCE_MISSING', + LoadDatasourcePending = 'explore/LOAD_DATASOURCE_PENDING', + LoadDatasourceSuccess = 'explore/LOAD_DATASOURCE_SUCCESS', + ModifyQueries = 'explore/MODIFY_QUERIES', + QueryTransactionFailure = 'explore/QUERY_TRANSACTION_FAILURE', + QueryTransactionStart = 'explore/QUERY_TRANSACTION_START', + QueryTransactionSuccess = 'explore/QUERY_TRANSACTION_SUCCESS', + RemoveQueryRow = 'explore/REMOVE_QUERY_ROW', + RunQueries = 'explore/RUN_QUERIES', + RunQueriesEmpty = 'explore/RUN_QUERIES_EMPTY', + ScanRange = 'explore/SCAN_RANGE', + ScanStart = 'explore/SCAN_START', + ScanStop = 'explore/SCAN_STOP', + SetQueries = 'explore/SET_QUERIES', + SplitClose = 'explore/SPLIT_CLOSE', + SplitOpen = 'explore/SPLIT_OPEN', + StateSave = 'explore/STATE_SAVE', + ToggleGraph = 'explore/TOGGLE_GRAPH', + ToggleLogs = 'explore/TOGGLE_LOGS', + ToggleTable = 'explore/TOGGLE_TABLE', +} + +export interface AddQueryRowAction { + type: ActionTypes.AddQueryRow; + payload: { + exploreId: ExploreId; + index: number; + query: DataQuery; + }; +} + +export interface ChangeQueryAction { + type: ActionTypes.ChangeQuery; + payload: { + exploreId: ExploreId; + query: DataQuery; + index: number; + override: boolean; + }; +} + +export interface ChangeSizeAction { + type: ActionTypes.ChangeSize; + payload: { + exploreId: ExploreId; + width: number; + height: number; + }; +} + +export interface ChangeTimeAction { + type: ActionTypes.ChangeTime; + payload: { + exploreId: ExploreId; + range: TimeRange; + }; +} + +export interface ClearQueriesAction { + type: ActionTypes.ClearQueries; + payload: { + exploreId: ExploreId; + }; +} + +export interface HighlightLogsExpressionAction { + type: ActionTypes.HighlightLogsExpression; + payload: { + exploreId: ExploreId; + expressions: string[]; + }; +} + +export interface InitializeExploreAction { + type: ActionTypes.InitializeExplore; + payload: { + exploreId: ExploreId; + containerWidth: number; + datasource: string; + eventBridge: Emitter; + exploreDatasources: DataSourceSelectItem[]; + queries: DataQuery[]; + range: RawTimeRange; + }; +} + +export interface InitializeExploreSplitAction { + type: ActionTypes.InitializeExploreSplit; +} + +export interface LoadDatasourceFailureAction { + type: ActionTypes.LoadDatasourceFailure; + payload: { + exploreId: ExploreId; + error: string; + }; +} + +export interface LoadDatasourcePendingAction { + type: ActionTypes.LoadDatasourcePending; + payload: { + exploreId: ExploreId; + datasourceId: number; + }; +} + +export interface LoadDatasourceMissingAction { + type: ActionTypes.LoadDatasourceMissing; + payload: { + exploreId: ExploreId; + }; +} + +export interface LoadDatasourceSuccessAction { + type: ActionTypes.LoadDatasourceSuccess; + payload: { + exploreId: ExploreId; + StartPage?: any; + datasourceInstance: any; + history: HistoryItem[]; + initialDatasource: string; + initialQueries: DataQuery[]; + logsHighlighterExpressions?: any[]; + showingStartPage: boolean; + supportsGraph: boolean; + supportsLogs: boolean; + supportsTable: boolean; + }; +} + +export interface ModifyQueriesAction { + type: ActionTypes.ModifyQueries; + payload: { + exploreId: ExploreId; + modification: any; + index: number; + modifier: (queries: DataQuery[], modification: any) => DataQuery[]; + }; +} + +export interface QueryTransactionFailureAction { + type: ActionTypes.QueryTransactionFailure; + payload: { + exploreId: ExploreId; + queryTransactions: QueryTransaction[]; + }; +} + +export interface QueryTransactionStartAction { + type: ActionTypes.QueryTransactionStart; + payload: { + exploreId: ExploreId; + resultType: ResultType; + rowIndex: number; + transaction: QueryTransaction; + }; +} + +export interface QueryTransactionSuccessAction { + type: ActionTypes.QueryTransactionSuccess; + payload: { + exploreId: ExploreId; + history: HistoryItem[]; + queryTransactions: QueryTransaction[]; + }; +} + +export interface RemoveQueryRowAction { + type: ActionTypes.RemoveQueryRow; + payload: { + exploreId: ExploreId; + index: number; + }; +} + +export interface RunQueriesEmptyAction { + type: ActionTypes.RunQueriesEmpty; + payload: { + exploreId: ExploreId; + }; +} + +export interface ScanStartAction { + type: ActionTypes.ScanStart; + payload: { + exploreId: ExploreId; + scanner: RangeScanner; + }; +} + +export interface ScanRangeAction { + type: ActionTypes.ScanRange; + payload: { + exploreId: ExploreId; + range: RawTimeRange; + }; +} + +export interface ScanStopAction { + type: ActionTypes.ScanStop; + payload: { + exploreId: ExploreId; + }; +} + +export interface SetQueriesAction { + type: ActionTypes.SetQueries; + payload: { + exploreId: ExploreId; + queries: DataQuery[]; + }; +} + +export interface SplitCloseAction { + type: ActionTypes.SplitClose; +} + +export interface SplitOpenAction { + type: ActionTypes.SplitOpen; + payload: { + itemState: ExploreItemState; + }; +} + +export interface StateSaveAction { + type: ActionTypes.StateSave; +} + +export interface ToggleTableAction { + type: ActionTypes.ToggleTable; + payload: { + exploreId: ExploreId; + }; +} + +export interface ToggleGraphAction { + type: ActionTypes.ToggleGraph; + payload: { + exploreId: ExploreId; + }; +} + +export interface ToggleLogsAction { + type: ActionTypes.ToggleLogs; + payload: { + exploreId: ExploreId; + }; +} + +export type Action = + | AddQueryRowAction + | ChangeQueryAction + | ChangeSizeAction + | ChangeTimeAction + | ClearQueriesAction + | HighlightLogsExpressionAction + | InitializeExploreAction + | InitializeExploreSplitAction + | LoadDatasourceFailureAction + | LoadDatasourceMissingAction + | LoadDatasourcePendingAction + | LoadDatasourceSuccessAction + | ModifyQueriesAction + | QueryTransactionFailureAction + | QueryTransactionStartAction + | QueryTransactionSuccessAction + | RemoveQueryRowAction + | RunQueriesEmptyAction + | ScanRangeAction + | ScanStartAction + | ScanStopAction + | SetQueriesAction + | SplitCloseAction + | SplitOpenAction + | ToggleGraphAction + | ToggleLogsAction + | ToggleTableAction; diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts new file mode 100644 index 00000000000..ae0bce6a019 --- /dev/null +++ b/public/app/features/explore/state/actions.ts @@ -0,0 +1,757 @@ +import _ from 'lodash'; +import { ThunkAction } from 'redux-thunk'; +import { RawTimeRange, TimeRange } from '@grafana/ui'; + +import { + LAST_USED_DATASOURCE_KEY, + clearQueryKeys, + ensureQueries, + generateEmptyQuery, + hasNonEmptyQuery, + makeTimeSeriesList, + updateHistory, + buildQueryTransaction, + serializeStateToUrlParam, +} from 'app/core/utils/explore'; + +import { updateLocation } from 'app/core/actions'; +import store from 'app/core/store'; +import { DataSourceSelectItem } from 'app/types/datasources'; +import { DataQuery, StoreState } from 'app/types'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { + ExploreId, + ExploreUrlState, + RangeScanner, + ResultType, + QueryOptions, + QueryTransaction, + QueryHint, + QueryHintGetter, +} from 'app/types/explore'; +import { Emitter } from 'app/core/core'; + +import { + Action as ThunkableAction, + ActionTypes, + AddQueryRowAction, + ChangeSizeAction, + HighlightLogsExpressionAction, + LoadDatasourceFailureAction, + LoadDatasourceMissingAction, + LoadDatasourcePendingAction, + LoadDatasourceSuccessAction, + QueryTransactionStartAction, + ScanStopAction, +} from './actionTypes'; + +type ThunkResult = ThunkAction; + +/** + * Adds a query row after the row with the given index. + */ +export function addQueryRow(exploreId: ExploreId, index: number): AddQueryRowAction { + const query = generateEmptyQuery(index + 1); + return { type: ActionTypes.AddQueryRow, payload: { exploreId, index, query } }; +} + +/** + * Loads a new datasource identified by the given name. + */ +export function changeDatasource(exploreId: ExploreId, datasource: string): ThunkResult { + return async dispatch => { + const instance = await getDatasourceSrv().get(datasource); + dispatch(loadDatasource(exploreId, instance)); + }; +} + +/** + * Query change handler for the query row with the given index. + * If `override` is reset the query modifications and run the queries. Use this to set queries via a link. + */ +export function changeQuery( + exploreId: ExploreId, + query: DataQuery, + index: number, + override: boolean +): ThunkResult { + return dispatch => { + // Null query means reset + if (query === null) { + query = { ...generateEmptyQuery(index) }; + } + + dispatch({ type: ActionTypes.ChangeQuery, payload: { exploreId, query, index, override } }); + if (override) { + dispatch(runQueries(exploreId)); + } + }; +} + +/** + * Keep track of the Explore container size, in particular the width. + * The width will be used to calculate graph intervals (number of datapoints). + */ +export function changeSize( + exploreId: ExploreId, + { height, width }: { height: number; width: number } +): ChangeSizeAction { + return { type: ActionTypes.ChangeSize, payload: { exploreId, height, width } }; +} + +/** + * Change the time range of Explore. Usually called from the Timepicker or a graph interaction. + */ +export function changeTime(exploreId: ExploreId, range: TimeRange): ThunkResult { + return dispatch => { + dispatch({ type: ActionTypes.ChangeTime, payload: { exploreId, range } }); + dispatch(runQueries(exploreId)); + }; +} + +/** + * Clear all queries and results. + */ +export function clearQueries(exploreId: ExploreId): ThunkResult { + return dispatch => { + dispatch(scanStop(exploreId)); + dispatch({ type: ActionTypes.ClearQueries, payload: { exploreId } }); + dispatch(stateSave()); + }; +} + +/** + * Highlight expressions in the log results + */ +export function highlightLogsExpression(exploreId: ExploreId, expressions: string[]): HighlightLogsExpressionAction { + return { type: ActionTypes.HighlightLogsExpression, payload: { exploreId, expressions } }; +} + +/** + * Initialize Explore state with state from the URL and the React component. + * Call this only on components for with the Explore state has not been initialized. + */ +export function initializeExplore( + exploreId: ExploreId, + datasource: string, + queries: DataQuery[], + range: RawTimeRange, + containerWidth: number, + eventBridge: Emitter +): ThunkResult { + return async dispatch => { + const exploreDatasources: DataSourceSelectItem[] = getDatasourceSrv() + .getExternal() + .map(ds => ({ + value: ds.name, + name: ds.name, + meta: ds.meta, + })); + + dispatch({ + type: ActionTypes.InitializeExplore, + payload: { + exploreId, + containerWidth, + datasource, + eventBridge, + exploreDatasources, + queries, + range, + }, + }); + + if (exploreDatasources.length > 1) { + let instance; + if (datasource) { + instance = await getDatasourceSrv().get(datasource); + } else { + instance = await getDatasourceSrv().get(); + } + dispatch(loadDatasource(exploreId, instance)); + } else { + dispatch(loadDatasourceMissing(exploreId)); + } + }; +} + +/** + * Initialize the wrapper split state + */ +export function initializeExploreSplit() { + return async dispatch => { + dispatch({ type: ActionTypes.InitializeExploreSplit }); + }; +} + +/** + * Display an error that happened during the selection of a datasource + */ +export const loadDatasourceFailure = (exploreId: ExploreId, error: string): LoadDatasourceFailureAction => ({ + type: ActionTypes.LoadDatasourceFailure, + payload: { + exploreId, + error, + }, +}); + +/** + * Display an error when no datasources have been configured + */ +export const loadDatasourceMissing = (exploreId: ExploreId): LoadDatasourceMissingAction => ({ + type: ActionTypes.LoadDatasourceMissing, + payload: { exploreId }, +}); + +/** + * Start the async process of loading a datasource to display a loading indicator + */ +export const loadDatasourcePending = (exploreId: ExploreId, datasourceId: number): LoadDatasourcePendingAction => ({ + type: ActionTypes.LoadDatasourcePending, + payload: { + exploreId, + datasourceId, + }, +}); + +/** + * Datasource loading was successfully completed. The instance is stored in the state as well in case we need to + * run datasource-specific code. Existing queries are imported to the new datasource if an importer exists, + * e.g., Prometheus -> Loki queries. + */ +export const loadDatasourceSuccess = ( + exploreId: ExploreId, + instance: any, + queries: DataQuery[] +): LoadDatasourceSuccessAction => { + // Capabilities + const supportsGraph = instance.meta.metrics; + const supportsLogs = instance.meta.logs; + const supportsTable = instance.meta.tables; + // Custom components + const StartPage = instance.pluginExports.ExploreStartPage; + + const historyKey = `grafana.explore.history.${instance.meta.id}`; + const history = store.getObject(historyKey, []); + // Save last-used datasource + store.set(LAST_USED_DATASOURCE_KEY, instance.name); + + return { + type: ActionTypes.LoadDatasourceSuccess, + payload: { + exploreId, + StartPage, + datasourceInstance: instance, + history, + initialDatasource: instance.name, + initialQueries: queries, + showingStartPage: Boolean(StartPage), + supportsGraph, + supportsLogs, + supportsTable, + }, + }; +}; + +/** + * Main action to asynchronously load a datasource. Dispatches lots of smaller actions for feedback. + */ +export function loadDatasource(exploreId: ExploreId, instance: any): ThunkResult { + return async (dispatch, getState) => { + const datasourceId = instance.meta.id; + + // Keep ID to track selection + dispatch(loadDatasourcePending(exploreId, datasourceId)); + + let datasourceError = null; + try { + const testResult = await instance.testDatasource(); + datasourceError = testResult.status === 'success' ? null : testResult.message; + } catch (error) { + datasourceError = (error && error.statusText) || 'Network error'; + } + if (datasourceError) { + dispatch(loadDatasourceFailure(exploreId, datasourceError)); + return; + } + + if (datasourceId !== getState().explore[exploreId].requestedDatasourceId) { + // User already changed datasource again, discard results + return; + } + + if (instance.init) { + instance.init(); + } + + // Check if queries can be imported from previously selected datasource + const queries = getState().explore[exploreId].modifiedQueries; + let importedQueries = queries; + const origin = getState().explore[exploreId].datasourceInstance; + if (origin) { + if (origin.meta.id === instance.meta.id) { + // Keep same queries if same type of datasource + importedQueries = [...queries]; + } else if (instance.importQueries) { + // Datasource-specific importers + importedQueries = await instance.importQueries(queries, origin.meta); + } else { + // Default is blank queries + importedQueries = ensureQueries(); + } + } + + if (datasourceId !== getState().explore[exploreId].requestedDatasourceId) { + // User already changed datasource again, discard results + return; + } + + // Reset edit state with new queries + const nextQueries = importedQueries.map((q, i) => ({ + ...importedQueries[i], + ...generateEmptyQuery(i), + })); + + dispatch(loadDatasourceSuccess(exploreId, instance, nextQueries)); + dispatch(runQueries(exploreId)); + }; +} + +/** + * Action to modify a query given a datasource-specific modifier action. + * @param exploreId Explore area + * @param modification Action object with a type, e.g., ADD_FILTER + * @param index Optional query row index. If omitted, the modification is applied to all query rows. + * @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`. + */ +export function modifyQueries( + exploreId: ExploreId, + modification: any, + index: number, + modifier: any +): ThunkResult { + return dispatch => { + dispatch({ type: ActionTypes.ModifyQueries, payload: { exploreId, modification, index, modifier } }); + if (!modification.preventSubmit) { + dispatch(runQueries(exploreId)); + } + }; +} + +/** + * Mark a query transaction as failed with an error extracted from the query response. + * The transaction will be marked as `done`. + */ +export function queryTransactionFailure( + exploreId: ExploreId, + transactionId: string, + response: any, + datasourceId: string +): ThunkResult { + return (dispatch, getState) => { + const { datasourceInstance, queryTransactions } = getState().explore[exploreId]; + if (datasourceInstance.meta.id !== datasourceId || response.cancelled) { + // Navigated away, queries did not matter + return; + } + + // Transaction might have been discarded + if (!queryTransactions.find(qt => qt.id === transactionId)) { + return; + } + + console.error(response); + + let error: string; + let errorDetails: string; + if (response.data) { + if (typeof response.data === 'string') { + error = response.data; + } else if (response.data.error) { + error = response.data.error; + if (response.data.response) { + errorDetails = response.data.response; + } + } else { + throw new Error('Could not handle error response'); + } + } else if (response.message) { + error = response.message; + } else if (typeof response === 'string') { + error = response; + } else { + error = 'Unknown error during query transaction. Please check JS console logs.'; + } + + // Mark transactions as complete + const nextQueryTransactions = queryTransactions.map(qt => { + if (qt.id === transactionId) { + return { + ...qt, + error, + errorDetails, + done: true, + }; + } + return qt; + }); + + dispatch({ + type: ActionTypes.QueryTransactionFailure, + payload: { exploreId, queryTransactions: nextQueryTransactions }, + }); + }; +} + +/** + * Start a query transaction for the given result type. + * @param exploreId Explore area + * @param transaction Query options and `done` status. + * @param resultType Associate the transaction with a result viewer, e.g., Graph + * @param rowIndex Index is used to associate latency for this transaction with a query row + */ +export function queryTransactionStart( + exploreId: ExploreId, + transaction: QueryTransaction, + resultType: ResultType, + rowIndex: number +): QueryTransactionStartAction { + return { type: ActionTypes.QueryTransactionStart, payload: { exploreId, resultType, rowIndex, transaction } }; +} + +/** + * Complete a query transaction, mark the transaction as `done` and store query state in URL. + * If the transaction was started by a scanner, it keeps on scanning for more results. + * Side-effect: the query is stored in localStorage. + * @param exploreId Explore area + * @param transactionId ID + * @param result Response from `datasourceInstance.query()` + * @param latency Duration between request and response + * @param queries Queries from all query rows + * @param datasourceId Origin datasource instance, used to discard results if current datasource is different + */ +export function queryTransactionSuccess( + exploreId: ExploreId, + transactionId: string, + result: any, + latency: number, + queries: DataQuery[], + datasourceId: string +): ThunkResult { + return (dispatch, getState) => { + const { datasourceInstance, history, queryTransactions, scanner, scanning } = getState().explore[exploreId]; + + // If datasource already changed, results do not matter + if (datasourceInstance.meta.id !== datasourceId) { + return; + } + + // Transaction might have been discarded + const transaction = queryTransactions.find(qt => qt.id === transactionId); + if (!transaction) { + return; + } + + // Get query hints + let hints: QueryHint[]; + if (datasourceInstance.getQueryHints as QueryHintGetter) { + hints = datasourceInstance.getQueryHints(transaction.query, result); + } + + // Mark transactions as complete and attach result + const nextQueryTransactions = queryTransactions.map(qt => { + if (qt.id === transactionId) { + return { + ...qt, + hints, + latency, + result, + done: true, + }; + } + return qt; + }); + + // Side-effect: Saving history in localstorage + const nextHistory = updateHistory(history, datasourceId, queries); + + dispatch({ + type: ActionTypes.QueryTransactionSuccess, + payload: { + exploreId, + history: nextHistory, + queryTransactions: nextQueryTransactions, + }, + }); + + // Keep scanning for results if this was the last scanning transaction + if (scanning) { + if (_.size(result) === 0) { + const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done); + if (!other) { + const range = scanner(); + dispatch({ type: ActionTypes.ScanRange, payload: { exploreId, range } }); + } + } else { + // We can stop scanning if we have a result + dispatch(scanStop(exploreId)); + } + } + }; +} + +/** + * Remove query row of the given index, as well as associated query results. + */ +export function removeQueryRow(exploreId: ExploreId, index: number): ThunkResult { + return dispatch => { + dispatch({ type: ActionTypes.RemoveQueryRow, payload: { exploreId, index } }); + dispatch(runQueries(exploreId)); + }; +} + +/** + * Main action to run queries and dispatches sub-actions based on which result viewers are active + */ +export function runQueries(exploreId: ExploreId) { + return (dispatch, getState) => { + const { + datasourceInstance, + modifiedQueries, + showingLogs, + showingGraph, + showingTable, + supportsGraph, + supportsLogs, + supportsTable, + } = getState().explore[exploreId]; + + if (!hasNonEmptyQuery(modifiedQueries)) { + dispatch({ type: ActionTypes.RunQueriesEmpty, payload: { exploreId } }); + return; + } + + // Some datasource's query builders allow per-query interval limits, + // but we're using the datasource interval limit for now + const interval = datasourceInstance.interval; + + // Keep table queries first since they need to return quickly + if (showingTable && supportsTable) { + dispatch( + runQueriesForType( + exploreId, + 'Table', + { + interval, + format: 'table', + instant: true, + valueWithRefId: true, + }, + data => data[0] + ) + ); + } + if (showingGraph && supportsGraph) { + dispatch( + runQueriesForType( + exploreId, + 'Graph', + { + interval, + format: 'time_series', + instant: false, + }, + makeTimeSeriesList + ) + ); + } + if (showingLogs && supportsLogs) { + dispatch(runQueriesForType(exploreId, 'Logs', { interval, format: 'logs' })); + } + dispatch(stateSave()); + }; +} + +/** + * Helper action to build a query transaction object and handing the query to the datasource. + * @param exploreId Explore area + * @param resultType Result viewer that will be associated with this query result + * @param queryOptions Query options as required by the datasource's `query()` function. + * @param resultGetter Optional result extractor, e.g., if the result is a list and you only need the first element. + */ +function runQueriesForType( + exploreId: ExploreId, + resultType: ResultType, + queryOptions: QueryOptions, + resultGetter?: any +) { + return async (dispatch, getState) => { + const { + datasourceInstance, + eventBridge, + modifiedQueries: queries, + queryIntervals, + range, + scanning, + } = getState().explore[exploreId]; + const datasourceId = datasourceInstance.meta.id; + + // Run all queries concurrently + queries.forEach(async (query, rowIndex) => { + const transaction = buildQueryTransaction( + query, + rowIndex, + resultType, + queryOptions, + range, + queryIntervals, + scanning + ); + dispatch(queryTransactionStart(exploreId, transaction, resultType, rowIndex)); + try { + const now = Date.now(); + const res = await datasourceInstance.query(transaction.options); + eventBridge.emit('data-received', res.data || []); + const latency = Date.now() - now; + const results = resultGetter ? resultGetter(res.data) : res.data; + dispatch(queryTransactionSuccess(exploreId, transaction.id, results, latency, queries, datasourceId)); + } catch (response) { + eventBridge.emit('data-error', response); + dispatch(queryTransactionFailure(exploreId, transaction.id, response, datasourceId)); + } + }); + }; +} + +/** + * Start a scan for more results using the given scanner. + * @param exploreId Explore area + * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range + */ +export function scanStart(exploreId: ExploreId, scanner: RangeScanner): ThunkResult { + return dispatch => { + // Register the scanner + dispatch({ type: ActionTypes.ScanStart, payload: { exploreId, scanner } }); + // Scanning must trigger query run, and return the new range + const range = scanner(); + // Set the new range to be displayed + dispatch({ type: ActionTypes.ScanRange, payload: { exploreId, range } }); + }; +} + +/** + * Stop any scanning for more results. + */ +export function scanStop(exploreId: ExploreId): ScanStopAction { + return { type: ActionTypes.ScanStop, payload: { exploreId } }; +} + +/** + * Reset queries to the given queries. Any modifications will be discarded. + * Use this action for clicks on query examples. Triggers a query run. + */ +export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): ThunkResult { + return dispatch => { + // Inject react keys into query objects + const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery() })); + dispatch({ + type: ActionTypes.SetQueries, + payload: { + exploreId, + queries, + }, + }); + dispatch(runQueries(exploreId)); + }; +} + +/** + * Close the split view and save URL state. + */ +export function splitClose(): ThunkResult { + return dispatch => { + dispatch({ type: ActionTypes.SplitClose }); + dispatch(stateSave()); + }; +} + +/** + * Open the split view and copy the left state to be the right state. + * The right state is automatically initialized. + * The copy keeps all query modifications but wipes the query results. + */ +export function splitOpen(): ThunkResult { + return (dispatch, getState) => { + // Clone left state to become the right state + const leftState = getState().explore.left; + const itemState = { + ...leftState, + queryTransactions: [], + initialQueries: leftState.modifiedQueries.slice(), + }; + dispatch({ type: ActionTypes.SplitOpen, payload: { itemState } }); + dispatch(stateSave()); + }; +} + +/** + * Saves Explore state to URL using the `left` and `right` parameters. + * If split view is not active, `right` will not be set. + */ +export function stateSave() { + return (dispatch, getState) => { + const { left, right, split } = getState().explore; + const urlStates: { [index: string]: string } = {}; + const leftUrlState: ExploreUrlState = { + datasource: left.datasourceInstance.name, + queries: left.modifiedQueries.map(clearQueryKeys), + range: left.range, + }; + urlStates.left = serializeStateToUrlParam(leftUrlState, true); + if (split) { + const rightUrlState: ExploreUrlState = { + datasource: right.datasourceInstance.name, + queries: right.modifiedQueries.map(clearQueryKeys), + range: right.range, + }; + urlStates.right = serializeStateToUrlParam(rightUrlState, true); + } + dispatch(updateLocation({ query: urlStates })); + }; +} + +/** + * Expand/collapse the graph result viewer. When collapsed, graph queries won't be run. + */ +export function toggleGraph(exploreId: ExploreId): ThunkResult { + return (dispatch, getState) => { + dispatch({ type: ActionTypes.ToggleGraph, payload: { exploreId } }); + if (getState().explore[exploreId].showingGraph) { + dispatch(runQueries(exploreId)); + } + }; +} + +/** + * Expand/collapse the logs result viewer. When collapsed, log queries won't be run. + */ +export function toggleLogs(exploreId: ExploreId): ThunkResult { + return (dispatch, getState) => { + dispatch({ type: ActionTypes.ToggleLogs, payload: { exploreId } }); + if (getState().explore[exploreId].showingLogs) { + dispatch(runQueries(exploreId)); + } + }; +} + +/** + * Expand/collapse the table result viewer. When collapsed, table queries won't be run. + */ +export function toggleTable(exploreId: ExploreId): ThunkResult { + return (dispatch, getState) => { + dispatch({ type: ActionTypes.ToggleTable, payload: { exploreId } }); + if (getState().explore[exploreId].showingTable) { + dispatch(runQueries(exploreId)); + } + }; +} diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts new file mode 100644 index 00000000000..b112a5370e3 --- /dev/null +++ b/public/app/features/explore/state/reducers.ts @@ -0,0 +1,462 @@ +import { + calculateResultsFromQueryTransactions, + generateEmptyQuery, + getIntervals, + ensureQueries, +} from 'app/core/utils/explore'; +import { ExploreItemState, ExploreState, QueryTransaction } from 'app/types/explore'; +import { DataQuery } from 'app/types/series'; + +import { Action, ActionTypes } from './actionTypes'; + +export const DEFAULT_RANGE = { + from: 'now-6h', + to: 'now', +}; + +// Millies step for helper bar charts +const DEFAULT_GRAPH_INTERVAL = 15 * 1000; + +/** + * Returns a fresh Explore area state + */ +const makeExploreItemState = (): ExploreItemState => ({ + StartPage: undefined, + containerWidth: 0, + datasourceInstance: null, + datasourceError: null, + datasourceLoading: null, + datasourceMissing: false, + exploreDatasources: [], + history: [], + initialQueries: [], + initialized: false, + modifiedQueries: [], + queryTransactions: [], + queryIntervals: { interval: '15s', intervalMs: DEFAULT_GRAPH_INTERVAL }, + range: DEFAULT_RANGE, + scanning: false, + scanRange: null, + showingGraph: true, + showingLogs: true, + showingTable: true, + supportsGraph: null, + supportsLogs: null, + supportsTable: null, +}); + +/** + * Global Explore state that handles multiple Explore areas and the split state + */ +const initialExploreState: ExploreState = { + split: null, + left: makeExploreItemState(), + right: makeExploreItemState(), +}; + +/** + * Reducer for an Explore area, to be used by the global Explore reducer. + */ +const itemReducer = (state, action: Action): ExploreItemState => { + switch (action.type) { + case ActionTypes.AddQueryRow: { + const { initialQueries, modifiedQueries, queryTransactions } = state; + const { index, query } = action.payload; + + // Add new query row after given index, keep modifications of existing rows + const nextModifiedQueries = [ + ...modifiedQueries.slice(0, index + 1), + { ...query }, + ...initialQueries.slice(index + 1), + ]; + + // Add to initialQueries, which will cause a new row to be rendered + const nextQueries = [...initialQueries.slice(0, index + 1), { ...query }, ...initialQueries.slice(index + 1)]; + + // Ongoing transactions need to update their row indices + const nextQueryTransactions = queryTransactions.map(qt => { + if (qt.rowIndex > index) { + return { + ...qt, + rowIndex: qt.rowIndex + 1, + }; + } + return qt; + }); + + return { + ...state, + initialQueries: nextQueries, + logsHighlighterExpressions: undefined, + modifiedQueries: nextModifiedQueries, + queryTransactions: nextQueryTransactions, + }; + } + + case ActionTypes.ChangeQuery: { + const { initialQueries, queryTransactions } = state; + let { modifiedQueries } = state; + const { query, index, override } = action.payload; + + // Fast path: only change modifiedQueries to not trigger an update + modifiedQueries[index] = query; + if (!override) { + return { + ...state, + modifiedQueries, + }; + } + + // Override path: queries are completely reset + const nextQuery: DataQuery = { + ...query, + ...generateEmptyQuery(index), + }; + const nextQueries = [...initialQueries]; + nextQueries[index] = nextQuery; + modifiedQueries = [...nextQueries]; + + // Discard ongoing transaction related to row query + const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); + + return { + ...state, + initialQueries: nextQueries, + modifiedQueries: nextQueries.slice(), + queryTransactions: nextQueryTransactions, + }; + } + + case ActionTypes.ChangeSize: { + const { range, datasourceInstance } = state; + let interval = '1s'; + if (datasourceInstance && datasourceInstance.interval) { + interval = datasourceInstance.interval; + } + const containerWidth = action.payload.width; + const queryIntervals = getIntervals(range, interval, containerWidth); + return { ...state, containerWidth, queryIntervals }; + } + + case ActionTypes.ChangeTime: { + return { + ...state, + range: action.payload.range, + }; + } + + case ActionTypes.ClearQueries: { + const queries = ensureQueries(); + return { + ...state, + initialQueries: queries.slice(), + modifiedQueries: queries.slice(), + queryTransactions: [], + showingStartPage: Boolean(state.StartPage), + }; + } + + case ActionTypes.HighlightLogsExpression: { + const { expressions } = action.payload; + return { ...state, logsHighlighterExpressions: expressions }; + } + + case ActionTypes.InitializeExplore: { + const { containerWidth, datasource, eventBridge, exploreDatasources, queries, range } = action.payload; + return { + ...state, + containerWidth, + eventBridge, + exploreDatasources, + range, + initialDatasource: datasource, + initialQueries: queries, + initialized: true, + modifiedQueries: queries.slice(), + }; + } + + case ActionTypes.LoadDatasourceFailure: { + return { ...state, datasourceError: action.payload.error, datasourceLoading: false }; + } + + case ActionTypes.LoadDatasourceMissing: { + return { ...state, datasourceMissing: true, datasourceLoading: false }; + } + + case ActionTypes.LoadDatasourcePending: { + return { ...state, datasourceLoading: true, requestedDatasourceId: action.payload.datasourceId }; + } + + case ActionTypes.LoadDatasourceSuccess: { + const { containerWidth, range } = state; + const { + StartPage, + datasourceInstance, + history, + initialDatasource, + initialQueries, + showingStartPage, + supportsGraph, + supportsLogs, + supportsTable, + } = action.payload; + const queryIntervals = getIntervals(range, datasourceInstance.interval, containerWidth); + + return { + ...state, + queryIntervals, + StartPage, + datasourceInstance, + history, + initialDatasource, + initialQueries, + showingStartPage, + supportsGraph, + supportsLogs, + supportsTable, + datasourceLoading: false, + datasourceMissing: false, + logsHighlighterExpressions: undefined, + modifiedQueries: initialQueries.slice(), + queryTransactions: [], + }; + } + + case ActionTypes.ModifyQueries: { + const { initialQueries, modifiedQueries, queryTransactions } = state; + const { modification, index, modifier } = action.payload as any; + let nextQueries: DataQuery[]; + let nextQueryTransactions; + if (index === undefined) { + // Modify all queries + nextQueries = initialQueries.map((query, i) => ({ + ...modifier(modifiedQueries[i], modification), + ...generateEmptyQuery(i), + })); + // Discard all ongoing transactions + nextQueryTransactions = []; + } else { + // Modify query only at index + nextQueries = initialQueries.map((query, i) => { + // Synchronize all queries with local query cache to ensure consistency + // TODO still needed? + return i === index + ? { + ...modifier(modifiedQueries[i], modification), + ...generateEmptyQuery(i), + } + : query; + }); + nextQueryTransactions = queryTransactions + // Consume the hint corresponding to the action + .map(qt => { + if (qt.hints != null && qt.rowIndex === index) { + qt.hints = qt.hints.filter(hint => hint.fix.action !== modification); + } + return qt; + }) + // Preserve previous row query transaction to keep results visible if next query is incomplete + .filter(qt => modification.preventSubmit || qt.rowIndex !== index); + } + return { + ...state, + initialQueries: nextQueries, + modifiedQueries: nextQueries.slice(), + queryTransactions: nextQueryTransactions, + }; + } + + case ActionTypes.QueryTransactionFailure: { + const { queryTransactions } = action.payload; + return { + ...state, + queryTransactions, + showingStartPage: false, + }; + } + + case ActionTypes.QueryTransactionStart: { + const { datasourceInstance, queryIntervals, queryTransactions } = state; + const { resultType, rowIndex, transaction } = action.payload; + // Discarding existing transactions of same type + const remainingTransactions = queryTransactions.filter( + qt => !(qt.resultType === resultType && qt.rowIndex === rowIndex) + ); + + // Append new transaction + const nextQueryTransactions: QueryTransaction[] = [...remainingTransactions, transaction]; + + const results = calculateResultsFromQueryTransactions( + nextQueryTransactions, + datasourceInstance, + queryIntervals.intervalMs + ); + + return { + ...state, + ...results, + queryTransactions: nextQueryTransactions, + showingStartPage: false, + }; + } + + case ActionTypes.QueryTransactionSuccess: { + const { datasourceInstance, queryIntervals } = state; + const { history, queryTransactions } = action.payload; + const results = calculateResultsFromQueryTransactions( + queryTransactions, + datasourceInstance, + queryIntervals.intervalMs + ); + + return { + ...state, + ...results, + history, + queryTransactions, + showingStartPage: false, + }; + } + + case ActionTypes.RemoveQueryRow: { + const { datasourceInstance, initialQueries, queryIntervals, queryTransactions } = state; + let { modifiedQueries } = state; + const { index } = action.payload; + + modifiedQueries = [...modifiedQueries.slice(0, index), ...modifiedQueries.slice(index + 1)]; + + if (initialQueries.length <= 1) { + return state; + } + + const nextQueries = [...initialQueries.slice(0, index), ...initialQueries.slice(index + 1)]; + + // Discard transactions related to row query + const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); + const results = calculateResultsFromQueryTransactions( + nextQueryTransactions, + datasourceInstance, + queryIntervals.intervalMs + ); + + return { + ...state, + ...results, + initialQueries: nextQueries, + logsHighlighterExpressions: undefined, + modifiedQueries: nextQueries.slice(), + queryTransactions: nextQueryTransactions, + }; + } + + case ActionTypes.RunQueriesEmpty: { + return { ...state, queryTransactions: [] }; + } + + case ActionTypes.ScanRange: { + return { ...state, scanRange: action.payload.range }; + } + + case ActionTypes.ScanStart: { + return { ...state, scanning: true }; + } + + case ActionTypes.ScanStop: { + const { queryTransactions } = state; + const nextQueryTransactions = queryTransactions.filter(qt => qt.scanning && !qt.done); + return { ...state, queryTransactions: nextQueryTransactions, scanning: false, scanRange: undefined }; + } + + case ActionTypes.SetQueries: { + const { queries } = action.payload; + return { ...state, initialQueries: queries.slice(), modifiedQueries: queries.slice() }; + } + + case ActionTypes.ToggleGraph: { + const showingGraph = !state.showingGraph; + let nextQueryTransactions = state.queryTransactions; + if (!showingGraph) { + // Discard transactions related to Graph query + nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Graph'); + } + return { ...state, queryTransactions: nextQueryTransactions, showingGraph }; + } + + case ActionTypes.ToggleLogs: { + const showingLogs = !state.showingLogs; + let nextQueryTransactions = state.queryTransactions; + if (!showingLogs) { + // Discard transactions related to Logs query + nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Logs'); + } + return { ...state, queryTransactions: nextQueryTransactions, showingLogs }; + } + + case ActionTypes.ToggleTable: { + const showingTable = !state.showingTable; + if (showingTable) { + return { ...state, showingTable, queryTransactions: state.queryTransactions }; + } + + // Toggle off needs discarding of table queries and results + const nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table'); + const results = calculateResultsFromQueryTransactions( + nextQueryTransactions, + state.datasourceInstance, + state.queryIntervals.intervalMs + ); + + return { ...state, ...results, queryTransactions: nextQueryTransactions, showingTable }; + } + } + + return state; +}; + +/** + * Global Explore reducer that handles multiple Explore areas (left and right). + * Actions that have an `exploreId` get routed to the ExploreItemReducer. + */ +export const exploreReducer = (state = initialExploreState, action: Action): ExploreState => { + switch (action.type) { + case ActionTypes.SplitClose: { + return { + ...state, + split: false, + }; + } + + case ActionTypes.SplitOpen: { + return { + ...state, + split: true, + right: action.payload.itemState, + }; + } + + case ActionTypes.InitializeExploreSplit: { + return { + ...state, + split: true, + }; + } + } + + if (action.payload) { + const { exploreId } = action.payload as any; + if (exploreId !== undefined) { + const exploreItemState = state[exploreId]; + return { + ...state, + [exploreId]: itemReducer(exploreItemState, action), + }; + } + } + + return state; +}; + +export default { + explore: exploreReducer, +}; 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..5636bb3acdb 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -1,10 +1,12 @@ import { Value } from 'slate'; +import { RawTimeRange, TimeRange } from '@grafana/ui'; + +import { Emitter } from 'app/core/core'; +import { LogsModel } from 'app/core/logs_model'; +import TableModel from 'app/core/table_model'; +import { DataSourceSelectItem } from 'app/types/datasources'; 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 { /** @@ -76,6 +78,174 @@ export interface CompletionItemGroup { skipSort?: boolean; } +export enum ExploreId { + left = 'left', + right = 'right', +} + +/** + * Global Explore state + */ +export interface ExploreState { + /** + * True if split view is active. + */ + split: boolean; + /** + * Explore state of the left split (left is default in non-split view). + */ + left: ExploreItemState; + /** + * Explore state of the right area in split view. + */ + right: ExploreItemState; +} + +export interface ExploreItemState { + /** + * React component to be shown when no queries have been run yet, e.g., for a query language cheat sheet. + */ + StartPage?: any; + /** + * Width used for calculating the graph interval (can't have more datapoints than pixels) + */ + containerWidth: number; + /** + * Datasource instance that has been selected. Datasource-specific logic can be run on this object. + */ + datasourceInstance: any; + /** + * Error to be shown when datasource loading or testing failed. + */ + datasourceError: string; + /** + * True if the datasource is loading. `null` if the loading has not started yet. + */ + datasourceLoading: boolean | null; + /** + * True if there is no datasource to be selected. + */ + datasourceMissing: boolean; + /** + * Emitter to send events to the rest of Grafana. + */ + eventBridge?: Emitter; + /** + * List of datasources to be shown in the datasource selector. + */ + exploreDatasources: DataSourceSelectItem[]; + /** + * List of timeseries to be shown in the Explore graph result viewer. + */ + graphResult?: any[]; + /** + * History of recent queries. Datasource-specific and initialized via localStorage. + */ + history: HistoryItem[]; + /** + * Initial datasource for this Explore, e.g., set via URL. + */ + initialDatasource?: string; + /** + * Initial queries for this Explore, e.g., set via URL. Each query will be + * converted to a query row. Query edits should be tracked in `modifiedQueries` though. + */ + initialQueries: DataQuery[]; + /** + * True if this Explore area has been initialized. + * Used to distinguish URL state injection versus split view state injection. + */ + initialized: boolean; + /** + * Log line substrings to be highlighted as you type in a query field. + * Currently supports only the first query row. + */ + logsHighlighterExpressions?: string[]; + /** + * Log query result to be displayed in the logs result viewer. + */ + logsResult?: LogsModel; + /** + * Copy of `initialQueries` that tracks user edits. + * Don't connect this property to a react component as it is updated on every query change. + * Used when running queries. Needs to be reset to `initialQueries` when those are reset as well. + */ + modifiedQueries: DataQuery[]; + /** + * Query intervals for graph queries to determine how many datapoints to return. + * Needs to be updated when `datasourceInstance` or `containerWidth` is changed. + */ + queryIntervals: QueryIntervals; + /** + * List of query transaction to track query duration and query result. + * Graph/Logs/Table results are calculated on the fly from the transaction, + * based on the transaction's result types. Transaction also holds the row index + * so that results can be dropped and re-computed without running queries again + * when query rows are removed. + */ + queryTransactions: QueryTransaction[]; + /** + * Tracks datasource when selected in the datasource selector. + * Allows the selection to be discarded if something went wrong during the asynchronous + * loading of the datasource. + */ + requestedDatasourceId?: number; + /** + * Time range for this Explore. Managed by the time picker and used by all query runs. + */ + range: TimeRange | RawTimeRange; + /** + * Scanner function that calculates a new range, triggers a query run, and returns the new range. + */ + scanner?: RangeScanner; + /** + * True if scanning for more results is active. + */ + scanning?: boolean; + /** + * Current scanning range to be shown to the user while scanning is active. + */ + scanRange?: RawTimeRange; + /** + * True if graph result viewer is expanded. Query runs will contain graph queries. + */ + showingGraph: boolean; + /** + * True if logs result viewer is expanded. Query runs will contain logs queries. + */ + showingLogs: boolean; + /** + * True StartPage needs to be shown. Typically set to `false` once queries have been run. + */ + showingStartPage?: boolean; + /** + * True if table result viewer is expanded. Query runs will contain table queries. + */ + showingTable: boolean; + /** + * True if `datasourceInstance` supports graph queries. + */ + supportsGraph: boolean | null; + /** + * True if `datasourceInstance` supports logs queries. + */ + supportsLogs: boolean | null; + /** + * True if `datasourceInstance` supports table queries. + */ + supportsTable: boolean | null; + /** + * Table model that combines all query table results into a single table. + */ + tableResult?: TableModel; +} + +export interface ExploreUrlState { + datasource: string; + queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense + range: RawTimeRange; +} + export interface HistoryItem { ts: number; query: DataQuery; @@ -128,6 +298,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 +325,8 @@ export interface QueryTransaction { scanning?: boolean; } +export type RangeScanner = () => RawTimeRange; + export interface TextMatch { text: string; start: number; @@ -149,38 +334,4 @@ export interface TextMatch { end: number; } -export interface ExploreState { - StartPage?: any; - datasource: any; - datasourceError: any; - datasourceLoading: boolean | null; - datasourceMissing: boolean; - exploreDatasources: DataSourceSelectItem[]; - graphInterval: number; // in ms - graphResult?: any[]; - history: HistoryItem[]; - initialDatasource?: string; - initialQueries: DataQuery[]; - logsHighlighterExpressions?: string[]; - logsResult?: LogsModel; - queryTransactions: QueryTransaction[]; - range: RawTimeRange; - scanning?: boolean; - scanRange?: RawTimeRange; - showingGraph: boolean; - showingLogs: boolean; - showingStartPage?: boolean; - showingTable: boolean; - supportsGraph: boolean | null; - supportsLogs: boolean | null; - supportsTable: boolean | null; - tableResult?: TableModel; -} - -export interface ExploreUrlState { - datasource: string; - queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense - range: RawTimeRange; -} - export type ResultType = 'Graph' | 'Logs' | 'Table'; diff --git a/public/app/types/index.ts b/public/app/types/index.ts index 72da1c76ea8..ad9f19e2c9f 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 './explore'; export { Team, TeamsState, @@ -81,6 +82,7 @@ export interface StoreState { folder: FolderState; dashboard: DashboardState; dataSources: DataSourcesState; + explore: ExploreState; users: UsersState; organization: OrganizationState; appNotifications: AppNotificationsState; diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index 098dae1a4a2..abd13a10368 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -1,5 +1,5 @@ .explore { - width: 100%; + flex: 1 1 auto; &-container { padding: $dashboard-padding;