Merge pull request #13787 from grafana/davkal/explore-transactions

Explore: query transactions for faster result display
This commit is contained in:
David 2018-10-24 11:24:29 +02:00 committed by GitHub
commit 7d0eccdd23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 737 additions and 391 deletions

View File

@ -8,23 +8,17 @@ const DEFAULT_EXPLORE_STATE: ExploreState = {
datasourceMissing: false, datasourceMissing: false,
datasourceName: '', datasourceName: '',
exploreDatasources: [], exploreDatasources: [],
graphResult: null, graphRange: DEFAULT_RANGE,
history: [], history: [],
latency: 0,
loading: false,
logsResult: null,
queries: [], queries: [],
queryErrors: [], queryTransactions: [],
queryHints: [],
range: DEFAULT_RANGE, range: DEFAULT_RANGE,
requestOptions: null,
showingGraph: true, showingGraph: true,
showingLogs: true, showingLogs: true,
showingTable: true, showingTable: true,
supportsGraph: null, supportsGraph: null,
supportsLogs: null, supportsLogs: null,
supportsTable: null, supportsTable: null,
tableResult: null,
}; };
describe('state functions', () => { describe('state functions', () => {

View File

@ -1,8 +1,17 @@
import React from 'react'; import React from 'react';
import { hot } from 'react-hot-loader'; import { hot } from 'react-hot-loader';
import Select from 'react-select'; 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 kbn from 'app/core/utils/kbn';
import colors from 'app/core/utils/colors'; import colors from 'app/core/utils/colors';
import store from 'app/core/store'; 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 NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
import ElapsedTime from './ElapsedTime';
import QueryRows from './QueryRows'; import QueryRows from './QueryRows';
import Graph from './Graph'; import Graph from './Graph';
import Logs from './Logs'; import Logs from './Logs';
@ -25,16 +33,6 @@ import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
const MAX_HISTORY_ITEMS = 100; 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) { function makeTimeSeriesList(dataList, options) {
return dataList.map((seriesData, index) => { return dataList.map((seriesData, index) => {
const datapoints = seriesData.datapoints || []; 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 { interface ExploreProps {
datasourceSrv: any; datasourceSrv: any;
onChangeSplit: (split: boolean, state?: ExploreState) => void; onChangeSplit: (split: boolean, state?: ExploreState) => void;
@ -83,6 +100,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
} else { } else {
const { datasource, queries, range } = props.urlState as ExploreUrlState; const { datasource, queries, range } = props.urlState as ExploreUrlState;
initialQueries = ensureQueries(queries); initialQueries = ensureQueries(queries);
const initialRange = range || { ...DEFAULT_RANGE };
this.state = { this.state = {
datasource: null, datasource: null,
datasourceError: null, datasourceError: null,
@ -90,23 +108,17 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
datasourceMissing: false, datasourceMissing: false,
datasourceName: datasource, datasourceName: datasource,
exploreDatasources: [], exploreDatasources: [],
graphResult: null, graphRange: initialRange,
history: [], history: [],
latency: 0,
loading: false,
logsResult: null,
queries: initialQueries, queries: initialQueries,
queryErrors: [], queryTransactions: [],
queryHints: [], range: initialRange,
range: range || { ...DEFAULT_RANGE },
requestOptions: null,
showingGraph: true, showingGraph: true,
showingLogs: true, showingLogs: true,
showingTable: true, showingTable: true,
supportsGraph: null, supportsGraph: null,
supportsLogs: null, supportsLogs: null,
supportsTable: null, supportsTable: null,
tableResult: null,
}; };
} }
this.queryExpressions = initialQueries.map(q => q.query); this.queryExpressions = initialQueries.map(q => q.query);
@ -200,14 +212,32 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}; };
onAddQueryRow = index => { onAddQueryRow = index => {
const { queries } = this.state; // Local cache
this.queryExpressions[index + 1] = ''; this.queryExpressions[index + 1] = '';
const nextQueries = [
...queries.slice(0, index + 1), this.setState(state => {
{ query: '', key: generateQueryKey() }, const { queries, queryTransactions } = state;
...queries.slice(index + 1),
]; // Add row by generating new react key
this.setState({ queries: nextQueries }); 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 => { onChangeDatasource = async option => {
@ -215,12 +245,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
datasource: null, datasource: null,
datasourceError: null, datasourceError: null,
datasourceLoading: true, datasourceLoading: true,
graphResult: null, queryTransactions: [],
latency: 0,
logsResult: null,
queryErrors: [],
queryHints: [],
tableResult: null,
}); });
const datasourceName = option.value; const datasourceName = option.value;
const datasource = await this.props.datasourceSrv.get(datasourceName); const datasource = await this.props.datasourceSrv.get(datasourceName);
@ -231,24 +256,25 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
// Keep current value in local cache // Keep current value in local cache
this.queryExpressions[index] = value; this.queryExpressions[index] = value;
// Replace query row on override
if (override) { if (override) {
const { queries } = this.state; this.setState(state => {
const nextQuery: Query = { // Replace query row
key: generateQueryKey(index), const { queries, queryTransactions } = state;
query: value, const nextQuery: Query = {
}; key: generateQueryKey(index),
const nextQueries = [...queries]; query: value,
nextQueries[index] = nextQuery; };
const nextQueries = [...queries];
nextQueries[index] = nextQuery;
this.setState( // Discard ongoing transaction related to row query
{ const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
queryErrors: [],
queryHints: [], return {
queries: nextQueries, queries: nextQueries,
}, queryTransactions: nextQueryTransactions,
this.onSubmit };
); }, this.onSubmit);
} }
}; };
@ -264,13 +290,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
this.queryExpressions = ['']; this.queryExpressions = [''];
this.setState( this.setState(
{ {
graphResult: null,
logsResult: null,
latency: 0,
queries: ensureQueries(), queries: ensureQueries(),
queryErrors: [], queryTransactions: [],
queryHints: [],
tableResult: null,
}, },
this.saveState this.saveState
); );
@ -284,11 +305,41 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}; };
onClickGraphButton = () => { 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 = () => { 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 = () => { onClickSplit = () => {
@ -300,7 +351,22 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}; };
onClickTableButton = () => { 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) => { onClickTableCell = (columnKey: string, rowValue: string) => {
@ -308,39 +374,68 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}; };
onModifyQueries = (action: object, index?: number) => { onModifyQueries = (action: object, index?: number) => {
const { datasource, queries } = this.state; const { datasource } = this.state;
if (datasource && datasource.modifyQuery) { if (datasource && datasource.modifyQuery) {
let nextQueries; this.setState(
if (index === undefined) { state => {
// Modify all queries const { queries, queryTransactions } = state;
nextQueries = queries.map((q, i) => ({ let nextQueries;
key: generateQueryKey(i), let nextQueryTransactions;
query: datasource.modifyQuery(this.queryExpressions[i], action), if (index === undefined) {
})); // Modify all queries
} else { nextQueries = queries.map((q, i) => ({
// Modify query only at index key: generateQueryKey(i),
nextQueries = [ query: datasource.modifyQuery(this.queryExpressions[i], action),
...queries.slice(0, index), }));
{ // Discard all ongoing transactions
key: generateQueryKey(index), nextQueryTransactions = [];
query: datasource.modifyQuery(this.queryExpressions[index], action), } else {
}, // Modify query only at index
...queries.slice(index + 1), nextQueries = [
]; ...queries.slice(0, index),
} {
this.queryExpressions = nextQueries.map(q => q.query); key: generateQueryKey(index),
this.setState({ queries: nextQueries }, () => this.onSubmit()); 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 => { onRemoveQueryRow = index => {
const { queries } = this.state; // Remove from local cache
if (queries.length <= 1) { this.queryExpressions = [...this.queryExpressions.slice(0, index), ...this.queryExpressions.slice(index + 1)];
return;
} this.setState(
const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)]; state => {
this.queryExpressions = nextQueries.map(q => q.query); const { queries, queryTransactions } = state;
this.setState({ queries: nextQueries }, () => this.onSubmit()); 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 = () => { onSubmit = () => {
@ -349,7 +444,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
this.runTableQuery(); this.runTableQuery();
} }
if (showingGraph && supportsGraph) { if (showingGraph && supportsGraph) {
this.runGraphQuery(); this.runGraphQueries();
} }
if (showingLogs && supportsLogs) { if (showingLogs && supportsLogs) {
this.runLogsQuery(); this.runLogsQuery();
@ -357,32 +452,11 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
this.saveState(); this.saveState();
}; };
onQuerySuccess(datasourceId: string, queries: string[]): void { buildQueryOptions(
// save queries to history query: string,
let { history } = this.state; rowIndex: number,
const { datasource } = this.state; targetOptions: { format: string; hinting?: boolean; instant?: boolean }
) {
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 }) {
const { datasource, range } = this.state; const { datasource, range } = this.state;
const resolution = this.el.offsetWidth; const resolution = this.el.offsetWidth;
const absoluteRange = { const absoluteRange = {
@ -390,90 +464,235 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
to: parseDate(range.to, true), to: parseDate(range.to, true),
}; };
const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval); const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
const targets = this.queryExpressions.map((q, i) => ({ const targets = [
...targetOptions, {
// Target identifier is needed for table transformations ...targetOptions,
refId: i + 1, // Target identifier is needed for table transformations
expr: q, refId: rowIndex + 1,
})); expr: query,
},
];
// Clone range for query request
const queryRange: Range = { ...range };
return { return {
interval, interval,
range,
targets, 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; 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]; const queries = [...this.queryExpressions];
if (!hasQuery(queries)) { if (!hasQuery(queries)) {
return; return;
} }
this.setState({ latency: 0, loading: true, graphResult: null, queryErrors: [], queryHints: [] }); const { datasource } = this.state;
const now = Date.now(); const datasourceId = datasource.meta.id;
const options = this.buildQueryOptions({ format: 'time_series', instant: false, hinting: true }); // Run all queries concurrently
try { queries.forEach(async (query, rowIndex) => {
const res = await datasource.query(options); if (query) {
const result = makeTimeSeriesList(res.data, options); const transaction = this.startQueryTransaction(query, rowIndex, 'Graph', {
const queryHints = res.hints ? makeHints(res.hints) : []; format: 'time_series',
const latency = Date.now() - now; instant: false,
this.setState({ latency, loading: false, graphResult: result, queryHints, requestOptions: options }); });
this.onQuerySuccess(datasource.meta.id, queries); try {
} catch (response) { const now = Date.now();
console.error(response); const res = await datasource.query(transaction.options);
const queryError = response.data ? response.data.error : response; const latency = Date.now() - now;
this.setState({ loading: false, queryErrors: [queryError] }); 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() { async runTableQuery() {
const queries = [...this.queryExpressions]; const queries = [...this.queryExpressions];
const { datasource } = this.state;
if (!hasQuery(queries)) { if (!hasQuery(queries)) {
return; return;
} }
this.setState({ latency: 0, loading: true, queryErrors: [], queryHints: [], tableResult: null }); const { datasource } = this.state;
const now = Date.now(); const datasourceId = datasource.meta.id;
const options = this.buildQueryOptions({ // Run all queries concurrently
format: 'table', queries.forEach(async (query, rowIndex) => {
instant: true, 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() { async runLogsQuery() {
const queries = [...this.queryExpressions]; const queries = [...this.queryExpressions];
const { datasource } = this.state;
if (!hasQuery(queries)) { if (!hasQuery(queries)) {
return; return;
} }
this.setState({ latency: 0, loading: true, queryErrors: [], queryHints: [], logsResult: null }); const { datasource } = this.state;
const now = Date.now(); const datasourceId = datasource.meta.id;
const options = this.buildQueryOptions({ // Run all queries concurrently
format: 'logs', 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 => { request = url => {
@ -485,6 +704,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
// Copy state, but copy queries including modifications // Copy state, but copy queries including modifications
return { return {
...this.state, ...this.state,
queryTransactions: [],
queries: ensureQueries(this.queryExpressions.map(query => ({ query }))), queries: ensureQueries(this.queryExpressions.map(query => ({ query }))),
}; };
} }
@ -502,23 +722,17 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
datasourceLoading, datasourceLoading,
datasourceMissing, datasourceMissing,
exploreDatasources, exploreDatasources,
graphResult, graphRange,
history, history,
latency,
loading,
logsResult,
queries, queries,
queryErrors, queryTransactions,
queryHints,
range, range,
requestOptions,
showingGraph, showingGraph,
showingLogs, showingLogs,
showingTable, showingTable,
supportsGraph, supportsGraph,
supportsLogs, supportsLogs,
supportsTable, supportsTable,
tableResult,
} = this.state; } = this.state;
const showingBoth = showingGraph && showingTable; const showingBoth = showingGraph && showingTable;
const graphHeight = showingBoth ? '200px' : '400px'; const graphHeight = showingBoth ? '200px' : '400px';
@ -527,6 +741,20 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const tableButtonActive = showingBoth || showingTable ? 'active' : ''; const tableButtonActive = showingBoth || showingTable ? 'active' : '';
const exploreClass = split ? 'explore explore-split' : 'explore'; const exploreClass = split ? 'explore explore-split' : 'explore';
const selectedDatasource = datasource ? exploreDatasources.find(d => d.label === datasource.name) : undefined; 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 ( return (
<div className={exploreClass} ref={this.getRef}> <div className={exploreClass} ref={this.getRef}>
@ -584,9 +812,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
</div> </div>
<div className="navbar-buttons relative"> <div className="navbar-buttons relative">
<button className="btn navbar-button--primary" onClick={this.onSubmit}> <button className="btn navbar-button--primary" onClick={this.onSubmit}>
Run Query <i className="fa fa-level-down run-icon" /> Run Query{' '}
{loading ? <i className="fa fa-spinner fa-spin run-icon" /> : <i className="fa fa-level-down run-icon" />}
</button> </button>
{loading || latency ? <ElapsedTime time={latency} className="text-info" /> : null}
</div> </div>
</div> </div>
@ -605,8 +833,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
<QueryRows <QueryRows
history={history} history={history}
queries={queries} queries={queries}
queryErrors={queryErrors}
queryHints={queryHints}
request={this.request} request={this.request}
onAddQueryRow={this.onAddQueryRow} onAddQueryRow={this.onAddQueryRow}
onChangeQuery={this.onChangeQuery} onChangeQuery={this.onChangeQuery}
@ -614,6 +840,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
onExecuteQuery={this.onSubmit} onExecuteQuery={this.onSubmit}
onRemoveQueryRow={this.onRemoveQueryRow} onRemoveQueryRow={this.onRemoveQueryRow}
supportsLogs={supportsLogs} supportsLogs={supportsLogs}
transactions={queryTransactions}
/> />
<div className="result-options"> <div className="result-options">
{supportsGraph ? ( {supportsGraph ? (
@ -635,23 +862,22 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
<main className="m-t-2"> <main className="m-t-2">
{supportsGraph && {supportsGraph &&
showingGraph && showingGraph && (
graphResult && (
<Graph <Graph
data={graphResult} data={graphResult}
height={graphHeight} height={graphHeight}
loading={loading} loading={graphLoading}
id={`explore-graph-${position}`} id={`explore-graph-${position}`}
options={requestOptions} range={graphRange}
split={split} split={split}
/> />
)} )}
{supportsTable && showingTable ? ( {supportsTable && showingTable ? (
<div className="panel-container"> <div className="panel-container m-t-2">
<Table data={tableResult} loading={loading} onClickCell={this.onClickTableCell} /> <Table data={tableResult} loading={tableLoading} onClickCell={this.onClickTableCell} />
</div> </div>
) : null} ) : null}
{supportsLogs && showingLogs ? <Logs data={logsResult} loading={loading} /> : null} {supportsLogs && showingLogs ? <Logs data={logsResult} loading={logsLoading} /> : null}
</main> </main>
</div> </div>
) : null} ) : null}

View File

@ -4,24 +4,11 @@ import { Graph } from './Graph';
import { mockData } from './__mocks__/mockData'; import { mockData } from './__mocks__/mockData';
const setup = (propOverrides?: object) => { const setup = (propOverrides?: object) => {
const props = Object.assign( const props = {
{ data: mockData().slice(0, 19),
data: mockData().slice(0, 19), range: { from: 'now-6h', to: 'now' },
options: { ...propOverrides,
interval: '20s', };
range: { from: 'now-6h', to: 'now' },
targets: [
{
format: 'time_series',
instant: false,
hinting: true,
expr: 'prometheus_http_request_duration_seconds_bucket',
},
],
},
},
propOverrides
);
// Enzyme.shallow did not work well with jquery.flop. Mocking the draw function. // Enzyme.shallow did not work well with jquery.flop. Mocking the draw function.
Graph.prototype.draw = jest.fn(); Graph.prototype.draw = jest.fn();

View File

@ -5,6 +5,8 @@ import { withSize } from 'react-sizeme';
import 'vendor/flot/jquery.flot'; import 'vendor/flot/jquery.flot';
import 'vendor/flot/jquery.flot.time'; import 'vendor/flot/jquery.flot.time';
import { Range } from 'app/types/explore';
import * as dateMath from 'app/core/utils/datemath'; import * as dateMath from 'app/core/utils/datemath';
import TimeSeries from 'app/core/time_series2'; import TimeSeries from 'app/core/time_series2';
@ -74,7 +76,7 @@ interface GraphProps {
height?: string; // e.g., '200px' height?: string; // e.g., '200px'
id?: string; id?: string;
loading?: boolean; loading?: boolean;
options: any; range: Range;
split?: boolean; split?: boolean;
size?: { width: number; height: number }; size?: { width: number; height: number };
} }
@ -101,7 +103,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
componentDidUpdate(prevProps: GraphProps) { componentDidUpdate(prevProps: GraphProps) {
if ( if (
prevProps.data !== this.props.data || prevProps.data !== this.props.data ||
prevProps.options !== this.props.options || prevProps.range !== this.props.range ||
prevProps.split !== this.props.split || prevProps.split !== this.props.split ||
prevProps.height !== this.props.height || prevProps.height !== this.props.height ||
(prevProps.size && prevProps.size.width !== this.props.size.width) (prevProps.size && prevProps.size.width !== this.props.size.width)
@ -120,22 +122,22 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
}; };
draw() { draw() {
const { options: userOptions, size } = this.props; const { range, size } = this.props;
const data = this.getGraphData(); const data = this.getGraphData();
const $el = $(`#${this.props.id}`); const $el = $(`#${this.props.id}`);
if (!data) { let series = [{ data: [[0, 0]] }];
$el.empty();
return; 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; const ticks = (size.width || 0) / 100;
let { from, to } = userOptions.range; let { from, to } = range;
if (!moment.isMoment(from)) { if (!moment.isMoment(from)) {
from = dateMath.parse(from, false); from = dateMath.parse(from, false);
} }
@ -157,7 +159,6 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
const options = { const options = {
...FLOT_OPTIONS, ...FLOT_OPTIONS,
...dynamicOptions, ...dynamicOptions,
...userOptions,
}; };
$.plot($el, series, options); $.plot($el, series, options);
} }
@ -166,16 +167,10 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
const { height = '100px', id = 'graph', loading = false } = this.props; const { height = '100px', id = 'graph', loading = false } = this.props;
const data = this.getGraphData(); const data = this.getGraphData();
if (!loading && data.length === 0) {
return (
<div className="panel-container">
<div className="muted m-a-1">The queries returned no time series to graph.</div>
</div>
);
}
return ( return (
<div> <>
{this.props.data.length > MAX_NUMBER_OF_TIME_SERIES && {this.props.data &&
this.props.data.length > MAX_NUMBER_OF_TIME_SERIES &&
!this.state.showAllTimeSeries && ( !this.state.showAllTimeSeries && (
<div className="time-series-disclaimer"> <div className="time-series-disclaimer">
<i className="fa fa-fw fa-warning disclaimer-icon" /> <i className="fa fa-fw fa-warning disclaimer-icon" />
@ -186,10 +181,11 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
</div> </div>
)} )}
<div className="panel-container"> <div className="panel-container">
{loading && <div className="explore-graph__loader" />}
<div id={id} className="explore-graph" style={{ height }} /> <div id={id} className="explore-graph" style={{ height }} />
<Legend data={data} /> <Legend data={data} />
</div> </div>
</div> </>
); );
} }
} }

View File

@ -1,7 +1,18 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { QueryTransaction } from 'app/types/explore';
// TODO make this datasource-plugin-dependent // TODO make this datasource-plugin-dependent
import QueryField from './PromQueryField'; 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<any, {}> { class QueryRow extends PureComponent<any, {}> {
onChangeQuery = (value, override?: boolean) => { onChangeQuery = (value, override?: boolean) => {
@ -44,13 +55,19 @@ class QueryRow extends PureComponent<any, {}> {
}; };
render() { 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 ( return (
<div className="query-row"> <div className="query-row">
<div className="query-row-status">
<QueryTransactions transactions={transactions} />
</div>
<div className="query-row-field"> <div className="query-row-field">
<QueryField <QueryField
error={queryError} error={queryError}
hint={queryHint} hint={hint}
initialQuery={query} initialQuery={query}
history={history} history={history}
onClickHintFix={this.onClickHintFix} onClickHintFix={this.onClickHintFix}
@ -78,7 +95,7 @@ class QueryRow extends PureComponent<any, {}> {
export default class QueryRows extends PureComponent<any, {}> { export default class QueryRows extends PureComponent<any, {}> {
render() { render() {
const { className = '', queries, queryErrors, queryHints, ...handlers } = this.props; const { className = '', queries, queryHints, transactions, ...handlers } = this.props;
return ( return (
<div className={className}> <div className={className}>
{queries.map((q, index) => ( {queries.map((q, index) => (
@ -86,8 +103,7 @@ export default class QueryRows extends PureComponent<any, {}> {
key={q.key} key={q.key}
index={index} index={index}
query={q.query} query={q.query}
queryError={queryErrors[index]} transactions={transactions.filter(t => t.rowIndex === index)}
queryHint={queryHints[index]}
{...handlers} {...handlers}
/> />
))} ))}

View File

@ -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<QueryTransactionProps> {
render() {
const { transaction } = this.props;
const className = transaction.done ? 'query-transaction' : 'query-transaction query-transaction--loading';
return (
<div className={className}>
<div className="query-transaction__type">{transaction.resultType}:</div>
<div className="query-transaction__duration">
{transaction.done ? formatLatency(transaction.latency) : <ElapsedTime />}
</div>
</div>
);
}
}
interface QueryTransactionsProps {
transactions: QueryTransactionModel[];
}
export default class QueryTransactions extends PureComponent<QueryTransactionsProps> {
render() {
const { transactions } = this.props;
return (
<div className="query-transactions">
{transactions.map((t, i) => <QueryTransaction key={`${t.query}:${t.resultType}`} transaction={t} />)}
</div>
);
}
}

View File

@ -51,7 +51,7 @@ export default class Table extends PureComponent<TableProps> {
minRows={0} minRows={0}
noDataText={noDataText} noDataText={noDataText}
resolveData={data => prepareRows(data, columnNames)} resolveData={data => prepareRows(data, columnNames)}
showPagination={data} showPagination={Boolean(data)}
/> />
); );
} }

View File

@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = ` exports[`Render should render component 1`] = `
<div> <Fragment>
<div <div
className="panel-container" className="panel-container"
> >
@ -458,11 +458,11 @@ exports[`Render should render component 1`] = `
} }
/> />
</div> </div>
</div> </Fragment>
`; `;
exports[`Render should render component with disclaimer 1`] = ` exports[`Render should render component with disclaimer 1`] = `
<div> <Fragment>
<div <div
className="time-series-disclaimer" className="time-series-disclaimer"
> >
@ -956,17 +956,26 @@ exports[`Render should render component with disclaimer 1`] = `
} }
/> />
</div> </div>
</div> </Fragment>
`; `;
exports[`Render should show query return no time series 1`] = ` exports[`Render should show query return no time series 1`] = `
<div <Fragment>
className="panel-container"
>
<div <div
className="muted m-a-1" className="panel-container"
> >
The queries returned no time series to graph. <div
className="explore-graph"
id="graph"
style={
Object {
"height": "100px",
}
}
/>
<Legend
data={Array []}
/>
</div> </div>
</div> </Fragment>
`; `;

View File

@ -176,7 +176,6 @@ export class PrometheusDatasource {
return this.$q.all(allQueryPromise).then(responseList => { return this.$q.all(allQueryPromise).then(responseList => {
let result = []; let result = [];
let hints = [];
_.each(responseList, (response, index) => { _.each(responseList, (response, index) => {
if (response.status === 'error') { if (response.status === 'error') {
@ -196,19 +195,14 @@ export class PrometheusDatasource {
end: queries[index].end, end: queries[index].end,
query: queries[index].expr, query: queries[index].expr,
responseListLength: responseList.length, responseListLength: responseList.length,
responseIndex: index,
refId: activeTargets[index].refId, refId: activeTargets[index].refId,
valueWithRefId: activeTargets[index].valueWithRefId,
}; };
const series = this.resultTransformer.transform(response, transformerOptions); const series = this.resultTransformer.transform(response, transformerOptions);
result = [...result, ...series]; 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; return state;
} }
getQueryHints(query: string, result: any[]) {
return getQueryHints(query, result, this);
}
loadRules() { loadRules() {
this.metadataRequest('/api/v1/rules') this.metadataRequest('/api/v1/rules')
.then(res => res.data || res.json()) .then(res => res.data || res.json())

View File

@ -1,100 +1,92 @@
import _ from 'lodash'; import _ from 'lodash';
export function getQueryHints(series: any[], datasource?: any): any[] { export function getQueryHints(query: string, series?: any[], datasource?: any): any[] {
const hints = series.map((s, i) => { const hints = [];
const query: string = s.query;
const index: number = s.responseIndex;
if (query === undefined || index === undefined) {
return null;
}
// ..._bucket metric needs a histogram_quantile() // ..._bucket metric needs a histogram_quantile()
const histogramMetric = query.trim().match(/^\w+_bucket$/); const histogramMetric = query.trim().match(/^\w+_bucket$/);
if (histogramMetric) { if (histogramMetric) {
const label = 'Time series has buckets, you probably wanted a histogram.'; const label = 'Time series has buckets, you probably wanted a histogram.';
return { hints.push({
index, 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, label,
fix: { fix: {
label: 'Fix by adding histogram_quantile().', label: 'Expand rules',
action: { action: {
type: 'ADD_HISTOGRAM_QUANTILE', type: 'EXPAND_RULES',
query, 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 return hints.length > 0 ? hints : null;
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;
} }

View File

@ -8,7 +8,14 @@ export class ResultTransformer {
const prometheusResult = response.data.data.result; const prometheusResult = response.data.data.result;
if (options.format === 'table') { 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') { } else if (options.format === 'heatmap') {
let seriesList = []; let seriesList = [];
prometheusResult.sort(sortSeriesByLabel); prometheusResult.sort(sortSeriesByLabel);
@ -66,12 +73,11 @@ export class ResultTransformer {
return { return {
datapoints: dps, datapoints: dps,
query: options.query, query: options.query,
responseIndex: options.responseIndex,
target: metricLabel, target: metricLabel,
}; };
} }
transformMetricDataToTable(md, resultCount: number, refId: string) { transformMetricDataToTable(md, resultCount: number, refId: string, valueWithRefId?: boolean) {
const table = new TableModel(); const table = new TableModel();
let i, j; let i, j;
const metricLabels = {}; const metricLabels = {};
@ -96,7 +102,7 @@ export class ResultTransformer {
metricLabels[label] = labelIndex + 1; metricLabels[label] = labelIndex + 1;
table.columns.push({ text: label, filterable: !label.startsWith('__') }); 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 }); table.columns.push({ text: valueText });
// Populate rows, set value to empty string when label not present. // Populate rows, set value to empty string when label not present.

View File

@ -2,34 +2,31 @@ import { getQueryHints } from '../query_hints';
describe('getQueryHints()', () => { describe('getQueryHints()', () => {
it('returns no hints for no series', () => { it('returns no hints for no series', () => {
expect(getQueryHints([])).toEqual([]); expect(getQueryHints('', [])).toEqual(null);
}); });
it('returns no hints for empty series', () => { 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', () => { it('returns no hint for a monotonously decreasing series', () => {
const series = [{ datapoints: [[23, 1000], [22, 1001]], query: 'metric', responseIndex: 0 }]; const series = [{ datapoints: [[23, 1000], [22, 1001]] }];
const hints = getQueryHints(series); const hints = getQueryHints('metric', series);
expect(hints).toEqual([null]); expect(hints).toEqual(null);
}); });
it('returns no hint for a flat series', () => { it('returns no hint for a flat series', () => {
const series = [ const series = [{ datapoints: [[null, 1000], [23, 1001], [null, 1002], [23, 1003]] }];
{ datapoints: [[null, 1000], [23, 1001], [null, 1002], [23, 1003]], query: 'metric', responseIndex: 0 }, const hints = getQueryHints('metric', series);
]; expect(hints).toEqual(null);
const hints = getQueryHints(series);
expect(hints).toEqual([null]);
}); });
it('returns a rate hint for a monotonously increasing series', () => { it('returns a rate hint for a monotonously increasing series', () => {
const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'metric', responseIndex: 0 }]; const series = [{ datapoints: [[23, 1000], [24, 1001]] }];
const hints = getQueryHints(series); const hints = getQueryHints('metric', series);
expect(hints.length).toBe(1); expect(hints.length).toBe(1);
expect(hints[0]).toMatchObject({ expect(hints[0]).toMatchObject({
label: 'Time series is monotonously increasing.', label: 'Time series is monotonously increasing.',
index: 0,
fix: { fix: {
action: { action: {
type: 'ADD_RATE', type: 'ADD_RATE',
@ -40,26 +37,25 @@ describe('getQueryHints()', () => {
}); });
it('returns no rate hint for a monotonously increasing series that already has a rate', () => { 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 series = [{ datapoints: [[23, 1000], [24, 1001]] }];
const hints = getQueryHints(series); const hints = getQueryHints('rate(metric[1m])', series);
expect(hints).toEqual([null]); expect(hints).toEqual(null);
}); });
it('returns a rate hint w/o action for a complex monotonously increasing series', () => { 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 series = [{ datapoints: [[23, 1000], [24, 1001]] }];
const hints = getQueryHints(series); const hints = getQueryHints('sum(metric)', series);
expect(hints.length).toBe(1); expect(hints.length).toBe(1);
expect(hints[0].label).toContain('rate()'); expect(hints[0].label).toContain('rate()');
expect(hints[0].fix).toBeUndefined(); expect(hints[0].fix).toBeUndefined();
}); });
it('returns a rate hint for a monotonously increasing series with missing data', () => { 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 series = [{ datapoints: [[23, 1000], [null, 1001], [24, 1002]] }];
const hints = getQueryHints(series); const hints = getQueryHints('metric', series);
expect(hints.length).toBe(1); expect(hints.length).toBe(1);
expect(hints[0]).toMatchObject({ expect(hints[0]).toMatchObject({
label: 'Time series is monotonously increasing.', label: 'Time series is monotonously increasing.',
index: 0,
fix: { fix: {
action: { action: {
type: 'ADD_RATE', type: 'ADD_RATE',
@ -70,12 +66,11 @@ describe('getQueryHints()', () => {
}); });
it('returns a histogram hint for a bucket series', () => { it('returns a histogram hint for a bucket series', () => {
const series = [{ datapoints: [[23, 1000]], query: 'metric_bucket', responseIndex: 0 }]; const series = [{ datapoints: [[23, 1000]] }];
const hints = getQueryHints(series); const hints = getQueryHints('metric_bucket', series);
expect(hints.length).toBe(1); expect(hints.length).toBe(1);
expect(hints[0]).toMatchObject({ expect(hints[0]).toMatchObject({
label: 'Time series has buckets, you probably wanted a histogram.', label: 'Time series has buckets, you probably wanted a histogram.',
index: 0,
fix: { fix: {
action: { action: {
type: 'ADD_HISTOGRAM_QUANTILE', type: 'ADD_HISTOGRAM_QUANTILE',

View File

@ -3,6 +3,11 @@ interface ExploreDatasource {
label: string; label: string;
} }
export interface HistoryItem {
ts: number;
query: string;
}
export interface Range { export interface Range {
from: string; from: string;
to: string; to: string;
@ -13,6 +18,19 @@ export interface Query {
key?: string; 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 { export interface TextMatch {
text: string; text: string;
start: number; start: number;
@ -27,34 +45,25 @@ export interface ExploreState {
datasourceMissing: boolean; datasourceMissing: boolean;
datasourceName?: string; datasourceName?: string;
exploreDatasources: ExploreDatasource[]; exploreDatasources: ExploreDatasource[];
graphResult: any; graphRange: Range;
history: any[]; history: HistoryItem[];
latency: number;
loading: any;
logsResult: any;
/** /**
* Initial rows of queries to push down the tree. * Initial rows of queries to push down the tree.
* Modifications do not end up here, but in `this.queryExpressions`. * Modifications do not end up here, but in `this.queryExpressions`.
* The only way to reset a query is to change its `key`. * The only way to reset a query is to change its `key`.
*/ */
queries: Query[]; queries: Query[];
/**
* Errors caused by the running the query row.
*/
queryErrors: any[];
/** /**
* Hints gathered for the query row. * Hints gathered for the query row.
*/ */
queryHints: any[]; queryTransactions: QueryTransaction[];
range: Range; range: Range;
requestOptions: any;
showingGraph: boolean; showingGraph: boolean;
showingLogs: boolean; showingLogs: boolean;
showingTable: boolean; showingTable: boolean;
supportsGraph: boolean | null; supportsGraph: boolean | null;
supportsLogs: boolean | null; supportsLogs: boolean | null;
supportsTable: boolean | null; supportsTable: boolean | null;
tableResult: any;
} }
export interface ExploreUrlState { export interface ExploreUrlState {
@ -62,3 +71,5 @@ export interface ExploreUrlState {
queries: Query[]; queries: Query[];
range: Range; range: Range;
} }
export type ResultType = 'Graph' | 'Logs' | 'Table';

View File

@ -74,7 +74,7 @@
} }
} }
.elapsed-time { .navbar .elapsed-time {
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
@ -87,6 +87,37 @@
flex-wrap: wrap; 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 { .datasource-picker {
min-width: 200px; min-width: 200px;
} }
@ -119,6 +150,7 @@
.query-row { .query-row {
display: flex; display: flex;
position: relative;
& + & { & + & {
margin-top: 0.5rem; margin-top: 0.5rem;
@ -129,11 +161,53 @@
white-space: nowrap; 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 { .query-row-field {
margin-right: 3px; margin-right: 3px;
width: 100%; 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 { .explore {
.logs { .logs {
.logs-entries { .logs-entries {