diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index 915b47e14e2..4252730338d 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -8,23 +8,17 @@ const DEFAULT_EXPLORE_STATE: ExploreState = { datasourceMissing: false, datasourceName: '', exploreDatasources: [], - graphResult: null, + graphRange: DEFAULT_RANGE, history: [], - latency: 0, - loading: false, - logsResult: null, queries: [], - queryErrors: [], - queryHints: [], + queryTransactions: [], range: DEFAULT_RANGE, - requestOptions: null, showingGraph: true, showingLogs: true, showingTable: true, supportsGraph: null, supportsLogs: null, supportsTable: null, - tableResult: null, }; describe('state functions', () => { diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index d7326a5bfd1..bac063116f1 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -1,8 +1,17 @@ import React from 'react'; import { hot } from 'react-hot-loader'; import Select from 'react-select'; +import _ from 'lodash'; -import { ExploreState, ExploreUrlState, Query } from 'app/types/explore'; +import { + ExploreState, + ExploreUrlState, + HistoryItem, + Query, + QueryTransaction, + Range, + ResultType, +} from 'app/types/explore'; import kbn from 'app/core/utils/kbn'; import colors from 'app/core/utils/colors'; import store from 'app/core/store'; @@ -15,7 +24,6 @@ import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer' import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage'; import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; -import ElapsedTime from './ElapsedTime'; import QueryRows from './QueryRows'; import Graph from './Graph'; import Logs from './Logs'; @@ -25,16 +33,6 @@ import { ensureQueries, generateQueryKey, hasQuery } from './utils/query'; const MAX_HISTORY_ITEMS = 100; -function makeHints(hints) { - const hintsByIndex = []; - hints.forEach(hint => { - if (hint) { - hintsByIndex[hint.index] = hint; - } - }); - return hintsByIndex; -} - function makeTimeSeriesList(dataList, options) { return dataList.map((seriesData, index) => { const datapoints = seriesData.datapoints || []; @@ -53,6 +51,25 @@ function makeTimeSeriesList(dataList, options) { }); } +/** + * Update the query history. Side-effect: store history in local storage + */ +function updateHistory(history: HistoryItem[], datasourceId: string, queries: string[]): HistoryItem[] { + const ts = Date.now(); + queries.forEach(query => { + history = [{ query, ts }, ...history]; + }); + + if (history.length > MAX_HISTORY_ITEMS) { + history = history.slice(0, MAX_HISTORY_ITEMS); + } + + // Combine all queries of a datasource type into one history + const historyKey = `grafana.explore.history.${datasourceId}`; + store.setObject(historyKey, history); + return history; +} + interface ExploreProps { datasourceSrv: any; onChangeSplit: (split: boolean, state?: ExploreState) => void; @@ -83,6 +100,7 @@ export class Explore extends React.PureComponent { } else { const { datasource, queries, range } = props.urlState as ExploreUrlState; initialQueries = ensureQueries(queries); + const initialRange = range || { ...DEFAULT_RANGE }; this.state = { datasource: null, datasourceError: null, @@ -90,23 +108,17 @@ export class Explore extends React.PureComponent { datasourceMissing: false, datasourceName: datasource, exploreDatasources: [], - graphResult: null, + graphRange: initialRange, history: [], - latency: 0, - loading: false, - logsResult: null, queries: initialQueries, - queryErrors: [], - queryHints: [], - range: range || { ...DEFAULT_RANGE }, - requestOptions: null, + queryTransactions: [], + range: initialRange, showingGraph: true, showingLogs: true, showingTable: true, supportsGraph: null, supportsLogs: null, supportsTable: null, - tableResult: null, }; } this.queryExpressions = initialQueries.map(q => q.query); @@ -200,14 +212,32 @@ export class Explore extends React.PureComponent { }; onAddQueryRow = index => { - const { queries } = this.state; + // Local cache this.queryExpressions[index + 1] = ''; - const nextQueries = [ - ...queries.slice(0, index + 1), - { query: '', key: generateQueryKey() }, - ...queries.slice(index + 1), - ]; - this.setState({ queries: nextQueries }); + + this.setState(state => { + const { queries, queryTransactions } = state; + + // Add row by generating new react key + const nextQueries = [ + ...queries.slice(0, index + 1), + { query: '', key: generateQueryKey() }, + ...queries.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 { queries: nextQueries, queryTransactions: nextQueryTransactions }; + }); }; onChangeDatasource = async option => { @@ -215,12 +245,7 @@ export class Explore extends React.PureComponent { datasource: null, datasourceError: null, datasourceLoading: true, - graphResult: null, - latency: 0, - logsResult: null, - queryErrors: [], - queryHints: [], - tableResult: null, + queryTransactions: [], }); const datasourceName = option.value; const datasource = await this.props.datasourceSrv.get(datasourceName); @@ -231,24 +256,25 @@ export class Explore extends React.PureComponent { // Keep current value in local cache this.queryExpressions[index] = value; - // Replace query row on override if (override) { - const { queries } = this.state; - const nextQuery: Query = { - key: generateQueryKey(index), - query: value, - }; - const nextQueries = [...queries]; - nextQueries[index] = nextQuery; + this.setState(state => { + // Replace query row + const { queries, queryTransactions } = state; + const nextQuery: Query = { + key: generateQueryKey(index), + query: value, + }; + const nextQueries = [...queries]; + nextQueries[index] = nextQuery; - this.setState( - { - queryErrors: [], - queryHints: [], + // Discard ongoing transaction related to row query + const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); + + return { queries: nextQueries, - }, - this.onSubmit - ); + queryTransactions: nextQueryTransactions, + }; + }, this.onSubmit); } }; @@ -264,13 +290,8 @@ export class Explore extends React.PureComponent { this.queryExpressions = ['']; this.setState( { - graphResult: null, - logsResult: null, - latency: 0, queries: ensureQueries(), - queryErrors: [], - queryHints: [], - tableResult: null, + queryTransactions: [], }, this.saveState ); @@ -284,11 +305,41 @@ export class Explore extends React.PureComponent { }; onClickGraphButton = () => { - this.setState(state => ({ showingGraph: !state.showingGraph })); + 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 => ({ showingLogs: !state.showingLogs })); + 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(); + } + } + ); }; onClickSplit = () => { @@ -300,7 +351,22 @@ export class Explore extends React.PureComponent { }; onClickTableButton = () => { - this.setState(state => ({ showingTable: !state.showingTable })); + this.setState( + state => { + const showingTable = !state.showingTable; + let nextQueryTransactions = state.queryTransactions; + if (!showingTable) { + // Discard transactions related to Table query + nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table'); + } + return { queryTransactions: nextQueryTransactions, showingTable }; + }, + () => { + if (this.state.showingTable) { + this.onSubmit(); + } + } + ); }; onClickTableCell = (columnKey: string, rowValue: string) => { @@ -308,39 +374,68 @@ export class Explore extends React.PureComponent { }; onModifyQueries = (action: object, index?: number) => { - const { datasource, queries } = this.state; + const { datasource } = this.state; if (datasource && datasource.modifyQuery) { - let nextQueries; - if (index === undefined) { - // Modify all queries - nextQueries = queries.map((q, i) => ({ - key: generateQueryKey(i), - query: datasource.modifyQuery(this.queryExpressions[i], action), - })); - } else { - // Modify query only at index - nextQueries = [ - ...queries.slice(0, index), - { - key: generateQueryKey(index), - query: datasource.modifyQuery(this.queryExpressions[index], action), - }, - ...queries.slice(index + 1), - ]; - } - this.queryExpressions = nextQueries.map(q => q.query); - this.setState({ queries: nextQueries }, () => this.onSubmit()); + this.setState( + state => { + const { queries, queryTransactions } = state; + let nextQueries; + let nextQueryTransactions; + if (index === undefined) { + // Modify all queries + nextQueries = queries.map((q, i) => ({ + key: generateQueryKey(i), + query: datasource.modifyQuery(this.queryExpressions[i], action), + })); + // Discard all ongoing transactions + nextQueryTransactions = []; + } else { + // Modify query only at index + nextQueries = [ + ...queries.slice(0, index), + { + key: generateQueryKey(index), + query: datasource.modifyQuery(this.queryExpressions[index], action), + }, + ...queries.slice(index + 1), + ]; + // Discard transactions related to row query + nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); + } + this.queryExpressions = nextQueries.map(q => q.query); + return { + queries: nextQueries, + queryTransactions: nextQueryTransactions, + }; + }, + () => this.onSubmit() + ); } }; onRemoveQueryRow = index => { - const { queries } = this.state; - if (queries.length <= 1) { - return; - } - const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)]; - this.queryExpressions = nextQueries.map(q => q.query); - this.setState({ queries: nextQueries }, () => this.onSubmit()); + // Remove from local cache + this.queryExpressions = [...this.queryExpressions.slice(0, index), ...this.queryExpressions.slice(index + 1)]; + + this.setState( + state => { + const { queries, queryTransactions } = state; + if (queries.length <= 1) { + return null; + } + // Remove row from react state + const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)]; + + // Discard transactions related to row query + const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); + + return { + queries: nextQueries, + queryTransactions: nextQueryTransactions, + }; + }, + () => this.onSubmit() + ); }; onSubmit = () => { @@ -349,7 +444,7 @@ export class Explore extends React.PureComponent { this.runTableQuery(); } if (showingGraph && supportsGraph) { - this.runGraphQuery(); + this.runGraphQueries(); } if (showingLogs && supportsLogs) { this.runLogsQuery(); @@ -357,32 +452,11 @@ export class Explore extends React.PureComponent { this.saveState(); }; - onQuerySuccess(datasourceId: string, queries: string[]): void { - // save queries to history - let { history } = this.state; - const { datasource } = this.state; - - if (datasource.meta.id !== datasourceId) { - // Navigated away, queries did not matter - return; - } - - const ts = Date.now(); - queries.forEach(query => { - history = [{ query, ts }, ...history]; - }); - - if (history.length > MAX_HISTORY_ITEMS) { - history = history.slice(0, MAX_HISTORY_ITEMS); - } - - // Combine all queries of a datasource type into one history - const historyKey = `grafana.explore.history.${datasourceId}`; - store.setObject(historyKey, history); - this.setState({ history }); - } - - buildQueryOptions(targetOptions: { format: string; hinting?: boolean; instant?: boolean }) { + buildQueryOptions( + query: string, + rowIndex: number, + targetOptions: { format: string; hinting?: boolean; instant?: boolean } + ) { const { datasource, range } = this.state; const resolution = this.el.offsetWidth; const absoluteRange = { @@ -390,90 +464,235 @@ export class Explore extends React.PureComponent { to: parseDate(range.to, true), }; const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval); - const targets = this.queryExpressions.map((q, i) => ({ - ...targetOptions, - // Target identifier is needed for table transformations - refId: i + 1, - expr: q, - })); + const targets = [ + { + ...targetOptions, + // Target identifier is needed for table transformations + refId: rowIndex + 1, + expr: query, + }, + ]; + + // Clone range for query request + const queryRange: Range = { ...range }; + return { interval, - range, targets, + range: queryRange, }; } - async runGraphQuery() { + startQueryTransaction(query: string, rowIndex: number, resultType: ResultType, options: any): QueryTransaction { + const queryOptions = this.buildQueryOptions(query, rowIndex, options); + const transaction: QueryTransaction = { + query, + resultType, + rowIndex, + id: generateQueryKey(), + done: false, + latency: 0, + options: queryOptions, + }; + + // Using updater style because we might be modifying queryTransactions in quick succession + this.setState(state => { + const { queryTransactions } = state; + // Discarding existing transactions of same type + const remainingTransactions = queryTransactions.filter( + qt => !(qt.resultType === resultType && qt.rowIndex === rowIndex) + ); + + // Append new transaction + const nextQueryTransactions = [...remainingTransactions, transaction]; + + return { + queryTransactions: nextQueryTransactions, + }; + }); + + return transaction; + } + + completeQueryTransaction( + transactionId: string, + result: any, + latency: number, + queries: string[], + datasourceId: string + ) { const { datasource } = this.state; + if (datasource.meta.id !== datasourceId) { + // Navigated away, queries did not matter + return; + } + + this.setState(state => { + const { history, queryTransactions } = state; + + // Transaction might have been discarded + const transaction = queryTransactions.find(qt => qt.id === transactionId); + if (!transaction) { + return null; + } + + // Get query hints + let hints; + if (datasource.getQueryHints) { + 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 nextHistory = updateHistory(history, datasourceId, queries); + + return { + history: nextHistory, + queryTransactions: nextQueryTransactions, + }; + }); + } + + discardTransactions(rowIndex: number) { + this.setState(state => { + const remainingTransactions = state.queryTransactions.filter(qt => qt.rowIndex !== rowIndex); + return { queryTransactions: remainingTransactions }; + }); + } + + failQueryTransaction(transactionId: string, error: string, datasourceId: string) { + const { datasource } = this.state; + if (datasource.meta.id !== datasourceId) { + // Navigated away, queries did not matter + return; + } + + this.setState(state => { + // Transaction might have been discarded + if (!state.queryTransactions.find(qt => qt.id === transactionId)) { + return null; + } + + // Mark transactions as complete + const nextQueryTransactions = state.queryTransactions.map(qt => { + if (qt.id === transactionId) { + return { + ...qt, + error, + done: true, + }; + } + return qt; + }); + + return { + queryTransactions: nextQueryTransactions, + }; + }); + } + + async runGraphQueries() { const queries = [...this.queryExpressions]; if (!hasQuery(queries)) { return; } - this.setState({ latency: 0, loading: true, graphResult: null, queryErrors: [], queryHints: [] }); - const now = Date.now(); - const options = this.buildQueryOptions({ format: 'time_series', instant: false, hinting: true }); - try { - const res = await datasource.query(options); - const result = makeTimeSeriesList(res.data, options); - const queryHints = res.hints ? makeHints(res.hints) : []; - const latency = Date.now() - now; - this.setState({ latency, loading: false, graphResult: result, queryHints, requestOptions: options }); - this.onQuerySuccess(datasource.meta.id, queries); - } catch (response) { - console.error(response); - const queryError = response.data ? response.data.error : response; - this.setState({ loading: false, queryErrors: [queryError] }); - } + const { datasource } = this.state; + const datasourceId = datasource.meta.id; + // Run all queries concurrently + queries.forEach(async (query, rowIndex) => { + if (query) { + const transaction = this.startQueryTransaction(query, rowIndex, 'Graph', { + format: 'time_series', + instant: false, + }); + try { + const now = Date.now(); + const res = await datasource.query(transaction.options); + const latency = Date.now() - now; + const results = makeTimeSeriesList(res.data, transaction.options); + this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); + this.setState({ graphRange: transaction.options.range }); + } catch (response) { + console.error(response); + const queryError = response.data ? response.data.error : response; + this.failQueryTransaction(transaction.id, queryError, datasourceId); + } + } else { + this.discardTransactions(rowIndex); + } + }); } async runTableQuery() { const queries = [...this.queryExpressions]; - const { datasource } = this.state; if (!hasQuery(queries)) { return; } - this.setState({ latency: 0, loading: true, queryErrors: [], queryHints: [], tableResult: null }); - const now = Date.now(); - const options = this.buildQueryOptions({ - format: 'table', - instant: true, + const { datasource } = this.state; + const datasourceId = datasource.meta.id; + // Run all queries concurrently + queries.forEach(async (query, rowIndex) => { + if (query) { + const transaction = this.startQueryTransaction(query, rowIndex, 'Table', { + format: 'table', + instant: true, + valueWithRefId: true, + }); + try { + const now = Date.now(); + const res = await datasource.query(transaction.options); + const latency = Date.now() - now; + const results = res.data[0]; + this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); + } catch (response) { + console.error(response); + const queryError = response.data ? response.data.error : response; + this.failQueryTransaction(transaction.id, queryError, datasourceId); + } + } else { + this.discardTransactions(rowIndex); + } }); - try { - const res = await datasource.query(options); - const tableModel = mergeTablesIntoModel(new TableModel(), ...res.data); - const latency = Date.now() - now; - this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options }); - this.onQuerySuccess(datasource.meta.id, queries); - } catch (response) { - console.error(response); - const queryError = response.data ? response.data.error : response; - this.setState({ loading: false, queryErrors: [queryError] }); - } } async runLogsQuery() { const queries = [...this.queryExpressions]; - const { datasource } = this.state; if (!hasQuery(queries)) { return; } - this.setState({ latency: 0, loading: true, queryErrors: [], queryHints: [], logsResult: null }); - const now = Date.now(); - const options = this.buildQueryOptions({ - format: 'logs', + const { datasource } = this.state; + const datasourceId = datasource.meta.id; + // Run all queries concurrently + queries.forEach(async (query, rowIndex) => { + if (query) { + const transaction = this.startQueryTransaction(query, rowIndex, 'Logs', { format: 'logs' }); + try { + const now = Date.now(); + const res = await datasource.query(transaction.options); + const latency = Date.now() - now; + const results = res.data; + this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); + } catch (response) { + console.error(response); + const queryError = response.data ? response.data.error : response; + this.failQueryTransaction(transaction.id, queryError, datasourceId); + } + } else { + this.discardTransactions(rowIndex); + } }); - - try { - const res = await datasource.query(options); - const logsData = res.data; - const latency = Date.now() - now; - this.setState({ latency, loading: false, logsResult: logsData, requestOptions: options }); - this.onQuerySuccess(datasource.meta.id, queries); - } catch (response) { - console.error(response); - const queryError = response.data ? response.data.error : response; - this.setState({ loading: false, queryErrors: [queryError] }); - } } request = url => { @@ -485,6 +704,7 @@ export class Explore extends React.PureComponent { // Copy state, but copy queries including modifications return { ...this.state, + queryTransactions: [], queries: ensureQueries(this.queryExpressions.map(query => ({ query }))), }; } @@ -502,23 +722,17 @@ export class Explore extends React.PureComponent { datasourceLoading, datasourceMissing, exploreDatasources, - graphResult, + graphRange, history, - latency, - loading, - logsResult, queries, - queryErrors, - queryHints, + queryTransactions, range, - requestOptions, showingGraph, showingLogs, showingTable, supportsGraph, supportsLogs, supportsTable, - tableResult, } = this.state; const showingBoth = showingGraph && showingTable; const graphHeight = showingBoth ? '200px' : '400px'; @@ -527,6 +741,20 @@ export class Explore extends React.PureComponent { const tableButtonActive = showingBoth || showingTable ? 'active' : ''; const exploreClass = split ? 'explore explore-split' : 'explore'; const selectedDatasource = datasource ? exploreDatasources.find(d => d.label === datasource.name) : undefined; + const graphLoading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done); + const tableLoading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done); + const logsLoading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done); + const graphResult = _.flatten( + queryTransactions.filter(qt => qt.resultType === 'Graph' && qt.done && qt.result).map(qt => qt.result) + ); + const tableResult = mergeTablesIntoModel( + new TableModel(), + ...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done).map(qt => qt.result) + ); + const logsResult = _.flatten( + queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done).map(qt => qt.result) + ); + const loading = queryTransactions.some(qt => !qt.done); return (
@@ -584,9 +812,9 @@ export class Explore extends React.PureComponent {
- {loading || latency ? : null}
@@ -605,8 +833,6 @@ export class Explore extends React.PureComponent { { onExecuteQuery={this.onSubmit} onRemoveQueryRow={this.onRemoveQueryRow} supportsLogs={supportsLogs} + transactions={queryTransactions} />
{supportsGraph ? ( @@ -635,23 +862,22 @@ export class Explore extends React.PureComponent {
{supportsGraph && - showingGraph && - graphResult && ( + showingGraph && ( )} {supportsTable && showingTable ? ( -
- +
+
) : null} - {supportsLogs && showingLogs ? : null} + {supportsLogs && showingLogs ? : null} ) : null} diff --git a/public/app/features/explore/Graph.test.tsx b/public/app/features/explore/Graph.test.tsx index 043b843f0a5..fe4deaf17aa 100644 --- a/public/app/features/explore/Graph.test.tsx +++ b/public/app/features/explore/Graph.test.tsx @@ -4,24 +4,11 @@ import { Graph } from './Graph'; import { mockData } from './__mocks__/mockData'; const setup = (propOverrides?: object) => { - const props = Object.assign( - { - data: mockData().slice(0, 19), - options: { - interval: '20s', - range: { from: 'now-6h', to: 'now' }, - targets: [ - { - format: 'time_series', - instant: false, - hinting: true, - expr: 'prometheus_http_request_duration_seconds_bucket', - }, - ], - }, - }, - propOverrides - ); + const props = { + data: mockData().slice(0, 19), + range: { from: 'now-6h', to: 'now' }, + ...propOverrides, + }; // Enzyme.shallow did not work well with jquery.flop. Mocking the draw function. Graph.prototype.draw = jest.fn(); diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx index cb5def07fab..65dac2953e2 100644 --- a/public/app/features/explore/Graph.tsx +++ b/public/app/features/explore/Graph.tsx @@ -5,6 +5,8 @@ import { withSize } from 'react-sizeme'; import 'vendor/flot/jquery.flot'; import 'vendor/flot/jquery.flot.time'; + +import { Range } from 'app/types/explore'; import * as dateMath from 'app/core/utils/datemath'; import TimeSeries from 'app/core/time_series2'; @@ -74,7 +76,7 @@ interface GraphProps { height?: string; // e.g., '200px' id?: string; loading?: boolean; - options: any; + range: Range; split?: boolean; size?: { width: number; height: number }; } @@ -101,7 +103,7 @@ export class Graph extends PureComponent { componentDidUpdate(prevProps: GraphProps) { if ( prevProps.data !== this.props.data || - prevProps.options !== this.props.options || + prevProps.range !== this.props.range || prevProps.split !== this.props.split || prevProps.height !== this.props.height || (prevProps.size && prevProps.size.width !== this.props.size.width) @@ -120,22 +122,22 @@ export class Graph extends PureComponent { }; draw() { - const { options: userOptions, size } = this.props; + const { range, size } = this.props; const data = this.getGraphData(); const $el = $(`#${this.props.id}`); - if (!data) { - $el.empty(); - return; + let series = [{ data: [[0, 0]] }]; + + if (data && data.length > 0) { + series = data.map((ts: TimeSeries) => ({ + color: ts.color, + label: ts.label, + data: ts.getFlotPairs('null'), + })); } - const series = data.map((ts: TimeSeries) => ({ - color: ts.color, - label: ts.label, - data: ts.getFlotPairs('null'), - })); const ticks = (size.width || 0) / 100; - let { from, to } = userOptions.range; + let { from, to } = range; if (!moment.isMoment(from)) { from = dateMath.parse(from, false); } @@ -157,7 +159,6 @@ export class Graph extends PureComponent { const options = { ...FLOT_OPTIONS, ...dynamicOptions, - ...userOptions, }; $.plot($el, series, options); } @@ -166,16 +167,10 @@ export class Graph extends PureComponent { const { height = '100px', id = 'graph', loading = false } = this.props; const data = this.getGraphData(); - if (!loading && data.length === 0) { - return ( -
-
The queries returned no time series to graph.
-
- ); - } return ( -
- {this.props.data.length > MAX_NUMBER_OF_TIME_SERIES && + <> + {this.props.data && + this.props.data.length > MAX_NUMBER_OF_TIME_SERIES && !this.state.showAllTimeSeries && (
@@ -186,10 +181,11 @@ export class Graph extends PureComponent {
)}
+ {loading &&
}
-
+ ); } } diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx index 3d71c2f3566..0b0d7085d2d 100644 --- a/public/app/features/explore/QueryRows.tsx +++ b/public/app/features/explore/QueryRows.tsx @@ -1,7 +1,18 @@ import React, { PureComponent } from 'react'; +import { QueryTransaction } from 'app/types/explore'; + // TODO make this datasource-plugin-dependent import QueryField from './PromQueryField'; +import QueryTransactions from './QueryTransactions'; + +function getFirstHintFromTransactions(transactions: QueryTransaction[]) { + const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0); + if (transaction) { + return transaction.hints[0]; + } + return undefined; +} class QueryRow extends PureComponent { onChangeQuery = (value, override?: boolean) => { @@ -44,13 +55,19 @@ class QueryRow extends PureComponent { }; render() { - const { history, query, queryError, queryHint, request, supportsLogs } = this.props; + const { history, query, request, supportsLogs, transactions } = this.props; + const transactionWithError = transactions.find(t => t.error); + const hint = getFirstHintFromTransactions(transactions); + const queryError = transactionWithError ? transactionWithError.error : null; return (
+
+ +
{ export default class QueryRows extends PureComponent { render() { - const { className = '', queries, queryErrors, queryHints, ...handlers } = this.props; + const { className = '', queries, queryHints, transactions, ...handlers } = this.props; return (
{queries.map((q, index) => ( @@ -86,8 +103,7 @@ export default class QueryRows extends PureComponent { key={q.key} index={index} query={q.query} - queryError={queryErrors[index]} - queryHint={queryHints[index]} + transactions={transactions.filter(t => t.rowIndex === index)} {...handlers} /> ))} diff --git a/public/app/features/explore/QueryTransactions.tsx b/public/app/features/explore/QueryTransactions.tsx new file mode 100644 index 00000000000..0ce721f14e7 --- /dev/null +++ b/public/app/features/explore/QueryTransactions.tsx @@ -0,0 +1,42 @@ +import React, { PureComponent } from 'react'; + +import { QueryTransaction as QueryTransactionModel } from 'app/types/explore'; +import ElapsedTime from './ElapsedTime'; + +function formatLatency(value) { + return `${(value / 1000).toFixed(1)}s`; +} + +interface QueryTransactionProps { + transaction: QueryTransactionModel; +} + +class QueryTransaction extends PureComponent { + render() { + const { transaction } = this.props; + const className = transaction.done ? 'query-transaction' : 'query-transaction query-transaction--loading'; + return ( +
+
{transaction.resultType}:
+
+ {transaction.done ? formatLatency(transaction.latency) : } +
+
+ ); + } +} + +interface QueryTransactionsProps { + transactions: QueryTransactionModel[]; +} + +export default class QueryTransactions extends PureComponent { + render() { + const { transactions } = this.props; + return ( +
+ {transactions.map((t, i) => )} +
+ ); + } +} diff --git a/public/app/features/explore/Table.tsx b/public/app/features/explore/Table.tsx index fdb5d7de93a..0264bd3b4cc 100644 --- a/public/app/features/explore/Table.tsx +++ b/public/app/features/explore/Table.tsx @@ -51,7 +51,7 @@ export default class Table extends PureComponent { minRows={0} noDataText={noDataText} resolveData={data => prepareRows(data, columnNames)} - showPagination={data} + showPagination={Boolean(data)} /> ); } diff --git a/public/app/features/explore/__snapshots__/Graph.test.tsx.snap b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap index d6760dff59c..243fccb2e5f 100644 --- a/public/app/features/explore/__snapshots__/Graph.test.tsx.snap +++ b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Render should render component 1`] = ` -
+
@@ -458,11 +458,11 @@ exports[`Render should render component 1`] = ` } />
-
+ `; exports[`Render should render component with disclaimer 1`] = ` -
+
@@ -956,17 +956,26 @@ exports[`Render should render component with disclaimer 1`] = ` } />
-
+ `; exports[`Render should show query return no time series 1`] = ` -
+
- The queries returned no time series to graph. +
+
-
+
`; diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 856ab035ea0..89f88a946c2 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -176,7 +176,6 @@ export class PrometheusDatasource { return this.$q.all(allQueryPromise).then(responseList => { let result = []; - let hints = []; _.each(responseList, (response, index) => { if (response.status === 'error') { @@ -196,19 +195,14 @@ export class PrometheusDatasource { end: queries[index].end, query: queries[index].expr, responseListLength: responseList.length, - responseIndex: index, refId: activeTargets[index].refId, + valueWithRefId: activeTargets[index].valueWithRefId, }; const series = this.resultTransformer.transform(response, transformerOptions); result = [...result, ...series]; - - if (queries[index].hinting) { - const queryHints = getQueryHints(series, this); - hints = [...hints, ...queryHints]; - } }); - return { data: result, hints }; + return { data: result }; }); } @@ -437,6 +431,10 @@ export class PrometheusDatasource { return state; } + getQueryHints(query: string, result: any[]) { + return getQueryHints(query, result, this); + } + loadRules() { this.metadataRequest('/api/v1/rules') .then(res => res.data || res.json()) diff --git a/public/app/plugins/datasource/prometheus/query_hints.ts b/public/app/plugins/datasource/prometheus/query_hints.ts index 5efaadb77be..cfd04c766ba 100644 --- a/public/app/plugins/datasource/prometheus/query_hints.ts +++ b/public/app/plugins/datasource/prometheus/query_hints.ts @@ -1,100 +1,92 @@ import _ from 'lodash'; -export function getQueryHints(series: any[], datasource?: any): any[] { - const hints = series.map((s, i) => { - const query: string = s.query; - const index: number = s.responseIndex; - if (query === undefined || index === undefined) { - return null; - } +export function getQueryHints(query: string, series?: any[], datasource?: any): any[] { + const hints = []; - // ..._bucket metric needs a histogram_quantile() - const histogramMetric = query.trim().match(/^\w+_bucket$/); - if (histogramMetric) { - const label = 'Time series has buckets, you probably wanted a histogram.'; - return { - index, + // ..._bucket metric needs a histogram_quantile() + const histogramMetric = query.trim().match(/^\w+_bucket$/); + if (histogramMetric) { + const label = 'Time series has buckets, you probably wanted a histogram.'; + hints.push({ + type: 'HISTOGRAM_QUANTILE', + label, + fix: { + label: 'Fix by adding histogram_quantile().', + action: { + type: 'ADD_HISTOGRAM_QUANTILE', + query, + }, + }, + }); + } + + // Check for monotony on series (table results are being ignored here) + if (series && series.length > 0) { + series.forEach(s => { + const datapoints: number[][] = s.datapoints; + if (query.indexOf('rate(') === -1 && datapoints.length > 1) { + let increasing = false; + const nonNullData = datapoints.filter(dp => dp[0] !== null); + const monotonic = nonNullData.every((dp, index) => { + if (index === 0) { + return true; + } + increasing = increasing || dp[0] > nonNullData[index - 1][0]; + // monotonic? + return dp[0] >= nonNullData[index - 1][0]; + }); + if (increasing && monotonic) { + const simpleMetric = query.trim().match(/^\w+$/); + let label = 'Time series is monotonously increasing.'; + let fix; + if (simpleMetric) { + fix = { + label: 'Fix by adding rate().', + action: { + type: 'ADD_RATE', + query, + }, + }; + } else { + label = `${label} Try applying a rate() function.`; + } + hints.push({ + type: 'APPLY_RATE', + label, + fix, + }); + } + } + }); + } + + // Check for recording rules expansion + if (datasource && datasource.ruleMappings) { + const mapping = datasource.ruleMappings; + const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => { + if (query.search(ruleName) > -1) { + return { + ...acc, + [ruleName]: mapping[ruleName], + }; + } + return acc; + }, {}); + if (_.size(mappingForQuery) > 0) { + const label = 'Query contains recording rules.'; + hints.push({ + type: 'EXPAND_RULES', label, fix: { - label: 'Fix by adding histogram_quantile().', + label: 'Expand rules', action: { - type: 'ADD_HISTOGRAM_QUANTILE', + type: 'EXPAND_RULES', query, - index, + mapping: mappingForQuery, }, }, - }; - } - - // Check for monotony - const datapoints: number[][] = s.datapoints; - if (query.indexOf('rate(') === -1 && datapoints.length > 1) { - let increasing = false; - const nonNullData = datapoints.filter(dp => dp[0] !== null); - const monotonic = nonNullData.every((dp, index) => { - if (index === 0) { - return true; - } - increasing = increasing || dp[0] > nonNullData[index - 1][0]; - // monotonic? - return dp[0] >= nonNullData[index - 1][0]; }); - if (increasing && monotonic) { - const simpleMetric = query.trim().match(/^\w+$/); - let label = 'Time series is monotonously increasing.'; - let fix; - if (simpleMetric) { - fix = { - label: 'Fix by adding rate().', - action: { - type: 'ADD_RATE', - query, - index, - }, - }; - } else { - label = `${label} Try applying a rate() function.`; - } - return { - label, - index, - fix, - }; - } } - - // Check for recording rules expansion - if (datasource && datasource.ruleMappings) { - const mapping = datasource.ruleMappings; - const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => { - if (query.search(ruleName) > -1) { - return { - ...acc, - [ruleName]: mapping[ruleName], - }; - } - return acc; - }, {}); - if (_.size(mappingForQuery) > 0) { - const label = 'Query contains recording rules.'; - return { - label, - index, - fix: { - label: 'Expand rules', - action: { - type: 'EXPAND_RULES', - query, - index, - mapping: mappingForQuery, - }, - }, - }; - } - } - - // No hint found - return null; - }); - return hints; + } + return hints.length > 0 ? hints : null; } diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts index bf916bebf04..c9693eaf657 100644 --- a/public/app/plugins/datasource/prometheus/result_transformer.ts +++ b/public/app/plugins/datasource/prometheus/result_transformer.ts @@ -8,7 +8,14 @@ export class ResultTransformer { const prometheusResult = response.data.data.result; if (options.format === 'table') { - return [this.transformMetricDataToTable(prometheusResult, options.responseListLength, options.refId)]; + return [ + this.transformMetricDataToTable( + prometheusResult, + options.responseListLength, + options.refId, + options.valueWithRefId + ), + ]; } else if (options.format === 'heatmap') { let seriesList = []; prometheusResult.sort(sortSeriesByLabel); @@ -66,12 +73,11 @@ export class ResultTransformer { return { datapoints: dps, query: options.query, - responseIndex: options.responseIndex, target: metricLabel, }; } - transformMetricDataToTable(md, resultCount: number, refId: string) { + transformMetricDataToTable(md, resultCount: number, refId: string, valueWithRefId?: boolean) { const table = new TableModel(); let i, j; const metricLabels = {}; @@ -96,7 +102,7 @@ export class ResultTransformer { metricLabels[label] = labelIndex + 1; table.columns.push({ text: label, filterable: !label.startsWith('__') }); }); - const valueText = resultCount > 1 ? `Value #${refId}` : 'Value'; + const valueText = resultCount > 1 || valueWithRefId ? `Value #${refId}` : 'Value'; table.columns.push({ text: valueText }); // Populate rows, set value to empty string when label not present. diff --git a/public/app/plugins/datasource/prometheus/specs/query_hints.test.ts b/public/app/plugins/datasource/prometheus/specs/query_hints.test.ts index 2c72203137d..7eba54536fe 100644 --- a/public/app/plugins/datasource/prometheus/specs/query_hints.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/query_hints.test.ts @@ -2,34 +2,31 @@ import { getQueryHints } from '../query_hints'; describe('getQueryHints()', () => { it('returns no hints for no series', () => { - expect(getQueryHints([])).toEqual([]); + expect(getQueryHints('', [])).toEqual(null); }); it('returns no hints for empty series', () => { - expect(getQueryHints([{ datapoints: [], query: '' }])).toEqual([null]); + expect(getQueryHints('', [{ datapoints: [] }])).toEqual(null); }); it('returns no hint for a monotonously decreasing series', () => { - const series = [{ datapoints: [[23, 1000], [22, 1001]], query: 'metric', responseIndex: 0 }]; - const hints = getQueryHints(series); - expect(hints).toEqual([null]); + const series = [{ datapoints: [[23, 1000], [22, 1001]] }]; + const hints = getQueryHints('metric', series); + expect(hints).toEqual(null); }); it('returns no hint for a flat series', () => { - const series = [ - { datapoints: [[null, 1000], [23, 1001], [null, 1002], [23, 1003]], query: 'metric', responseIndex: 0 }, - ]; - const hints = getQueryHints(series); - expect(hints).toEqual([null]); + const series = [{ datapoints: [[null, 1000], [23, 1001], [null, 1002], [23, 1003]] }]; + const hints = getQueryHints('metric', series); + expect(hints).toEqual(null); }); it('returns a rate hint for a monotonously increasing series', () => { - const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'metric', responseIndex: 0 }]; - const hints = getQueryHints(series); + const series = [{ datapoints: [[23, 1000], [24, 1001]] }]; + const hints = getQueryHints('metric', series); expect(hints.length).toBe(1); expect(hints[0]).toMatchObject({ label: 'Time series is monotonously increasing.', - index: 0, fix: { action: { type: 'ADD_RATE', @@ -40,26 +37,25 @@ describe('getQueryHints()', () => { }); it('returns no rate hint for a monotonously increasing series that already has a rate', () => { - const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'rate(metric[1m])', responseIndex: 0 }]; - const hints = getQueryHints(series); - expect(hints).toEqual([null]); + const series = [{ datapoints: [[23, 1000], [24, 1001]] }]; + const hints = getQueryHints('rate(metric[1m])', series); + expect(hints).toEqual(null); }); it('returns a rate hint w/o action for a complex monotonously increasing series', () => { - const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'sum(metric)', responseIndex: 0 }]; - const hints = getQueryHints(series); + const series = [{ datapoints: [[23, 1000], [24, 1001]] }]; + const hints = getQueryHints('sum(metric)', series); expect(hints.length).toBe(1); expect(hints[0].label).toContain('rate()'); expect(hints[0].fix).toBeUndefined(); }); it('returns a rate hint for a monotonously increasing series with missing data', () => { - const series = [{ datapoints: [[23, 1000], [null, 1001], [24, 1002]], query: 'metric', responseIndex: 0 }]; - const hints = getQueryHints(series); + const series = [{ datapoints: [[23, 1000], [null, 1001], [24, 1002]] }]; + const hints = getQueryHints('metric', series); expect(hints.length).toBe(1); expect(hints[0]).toMatchObject({ label: 'Time series is monotonously increasing.', - index: 0, fix: { action: { type: 'ADD_RATE', @@ -70,12 +66,11 @@ describe('getQueryHints()', () => { }); it('returns a histogram hint for a bucket series', () => { - const series = [{ datapoints: [[23, 1000]], query: 'metric_bucket', responseIndex: 0 }]; - const hints = getQueryHints(series); + const series = [{ datapoints: [[23, 1000]] }]; + const hints = getQueryHints('metric_bucket', series); expect(hints.length).toBe(1); expect(hints[0]).toMatchObject({ label: 'Time series has buckets, you probably wanted a histogram.', - index: 0, fix: { action: { type: 'ADD_HISTOGRAM_QUANTILE', diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index f6072becec4..918dd4e4483 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -3,6 +3,11 @@ interface ExploreDatasource { label: string; } +export interface HistoryItem { + ts: number; + query: string; +} + export interface Range { from: string; to: string; @@ -13,6 +18,19 @@ export interface Query { key?: string; } +export interface QueryTransaction { + id: string; + done: boolean; + error?: string; + hints?: any[]; + latency: number; + options: any; + query: string; + result?: any; // Table model / Timeseries[] / Logs + resultType: ResultType; + rowIndex: number; +} + export interface TextMatch { text: string; start: number; @@ -27,34 +45,25 @@ export interface ExploreState { datasourceMissing: boolean; datasourceName?: string; exploreDatasources: ExploreDatasource[]; - graphResult: any; - history: any[]; - latency: number; - loading: any; - logsResult: any; + graphRange: Range; + history: HistoryItem[]; /** * Initial rows of queries to push down the tree. * Modifications do not end up here, but in `this.queryExpressions`. * The only way to reset a query is to change its `key`. */ queries: Query[]; - /** - * Errors caused by the running the query row. - */ - queryErrors: any[]; /** * Hints gathered for the query row. */ - queryHints: any[]; + queryTransactions: QueryTransaction[]; range: Range; - requestOptions: any; showingGraph: boolean; showingLogs: boolean; showingTable: boolean; supportsGraph: boolean | null; supportsLogs: boolean | null; supportsTable: boolean | null; - tableResult: any; } export interface ExploreUrlState { @@ -62,3 +71,5 @@ export interface ExploreUrlState { queries: Query[]; range: Range; } + +export type ResultType = 'Graph' | 'Logs' | 'Table'; diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index c1198ce06be..a3f60f2006b 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -74,7 +74,7 @@ } } - .elapsed-time { + .navbar .elapsed-time { position: absolute; left: 0; right: 0; @@ -87,6 +87,37 @@ flex-wrap: wrap; } + .explore-graph__loader { + height: 2px; + position: relative; + overflow: hidden; + background: $text-color-faint; + margin: $panel-margin / 2; + } + + .explore-graph__loader:after { + content: ' '; + display: block; + width: 25%; + top: 0; + top: -50%; + height: 250%; + position: absolute; + animation: loader 2s cubic-bezier(0.17, 0.67, 0.83, 0.67); + animation-iteration-count: 100; + z-index: 2; + background: $blue; + } + + @keyframes loader { + from { + left: -25%; + } + to { + left: 100%; + } + } + .datasource-picker { min-width: 200px; } @@ -119,6 +150,7 @@ .query-row { display: flex; + position: relative; & + & { margin-top: 0.5rem; @@ -129,11 +161,53 @@ white-space: nowrap; } +.query-row-status { + position: absolute; + top: 0; + right: 90px; + z-index: 1024; + display: flex; + flex-direction: column; + justify-content: center; + height: 34px; +} + .query-row-field { margin-right: 3px; width: 100%; } +.query-transactions { + display: table; +} + +.query-transaction { + display: table-row; + color: $text-color-faint; + line-height: 1.44; +} + +.query-transaction--loading { + animation: query-loading-color-change 1s alternate 100; +} + +@keyframes query-loading-color-change { + from { + color: $text-color-faint; + } + to { + color: $blue; + } +} + +.query-transaction__type, +.query-transaction__duration { + display: table-cell; + font-size: $font-size-xs; + text-align: right; + padding-right: 0.25em; +} + .explore { .logs { .logs-entries {