Merge pull request #13842 from grafana/davkal/explore-error-handling

Explore: error handling and time fixes
This commit is contained in:
David 2018-10-29 15:08:14 +01:00 committed by GitHub
commit b00e709aee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 203 additions and 123 deletions

View File

@ -83,6 +83,10 @@ function areRowsMatching(columns, row, otherRow) {
export function mergeTablesIntoModel(dst?: TableModel, ...tables: TableModel[]): TableModel { export function mergeTablesIntoModel(dst?: TableModel, ...tables: TableModel[]): TableModel {
const model = dst || new TableModel(); const model = dst || new TableModel();
if (arguments.length === 1) {
return model;
}
// Single query returns data columns and rows as is // Single query returns data columns and rows as is
if (arguments.length === 2) { if (arguments.length === 2) {
model.columns = [...tables[0].columns]; model.columns = [...tables[0].columns];

View File

@ -1,5 +1,8 @@
import _ from 'lodash'; import _ from 'lodash';
import moment from 'moment'; import moment from 'moment';
import { RawTimeRange } from 'app/types/series';
import * as dateMath from './datemath'; import * as dateMath from './datemath';
const spans = { const spans = {
@ -129,7 +132,7 @@ export function describeTextRange(expr: any) {
return opt; return opt;
} }
export function describeTimeRange(range) { export function describeTimeRange(range: RawTimeRange): string {
const option = rangeIndex[range.from.toString() + ' to ' + range.to.toString()]; const option = rangeIndex[range.from.toString() + ' to ' + range.to.toString()];
if (option) { if (option) {
return option.display; return option.display;

View File

@ -0,0 +1,34 @@
import React, { Component } from 'react';
export default class ErrorBoundary extends Component<{}, any> {
constructor(props) {
super(props);
this.state = { error: null, errorInfo: null };
}
componentDidCatch(error, errorInfo) {
// Catch errors in any components below and re-render with error message
this.setState({
error: error,
errorInfo: errorInfo,
});
}
render() {
if (this.state.errorInfo) {
// Error path
return (
<div className="explore-container">
<h3>An unexpected error happened.</h3>
<details style={{ whiteSpace: 'pre-wrap' }}>
{this.state.error && this.state.error.toString()}
<br />
{this.state.errorInfo.componentStack}
</details>
</div>
);
}
// Normally, just render children
return this.props.children;
}
}

View File

@ -3,15 +3,7 @@ import { hot } from 'react-hot-loader';
import Select from 'react-select'; import Select from 'react-select';
import _ from 'lodash'; import _ from 'lodash';
import { import { ExploreState, ExploreUrlState, HistoryItem, Query, QueryTransaction, ResultType } from 'app/types/explore';
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';
@ -24,12 +16,14 @@ 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 ErrorBoundary from './ErrorBoundary';
import QueryRows from './QueryRows'; import QueryRows from './QueryRows';
import Graph from './Graph'; import Graph from './Graph';
import Logs from './Logs'; import Logs from './Logs';
import Table from './Table'; import Table from './Table';
import TimePicker from './TimePicker'; import TimePicker from './TimePicker';
import { ensureQueries, generateQueryKey, hasQuery } from './utils/query'; import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
import { RawTimeRange } from 'app/types/series';
const MAX_HISTORY_ITEMS = 100; const MAX_HISTORY_ITEMS = 100;
@ -154,11 +148,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
} }
} }
componentDidCatch(error) {
this.setState({ datasourceError: error });
console.error(error);
}
async setDatasource(datasource) { async setDatasource(datasource) {
const supportsGraph = datasource.meta.metrics; const supportsGraph = datasource.meta.metrics;
const supportsLogs = datasource.meta.logs; const supportsLogs = datasource.meta.logs;
@ -170,7 +159,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const testResult = await datasource.testDatasource(); const testResult = await datasource.testDatasource();
datasourceError = testResult.status === 'success' ? null : testResult.message; datasourceError = testResult.status === 'success' ? null : testResult.message;
} catch (error) { } catch (error) {
datasourceError = (error && error.statusText) || error; datasourceError = (error && error.statusText) || 'Network error';
} }
const historyKey = `grafana.explore.history.${datasourceId}`; const historyKey = `grafana.explore.history.${datasourceId}`;
@ -278,10 +267,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
} }
}; };
onChangeTime = nextRange => { onChangeTime = (nextRange: RawTimeRange) => {
const range = { const range: RawTimeRange = {
from: nextRange.from, ...nextRange,
to: nextRange.to,
}; };
this.setState({ range }, () => this.onSubmit()); this.setState({ range }, () => this.onSubmit());
}; };
@ -471,7 +459,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
) { ) {
const { datasource, range } = this.state; const { datasource, range } = this.state;
const resolution = this.el.offsetWidth; const resolution = this.el.offsetWidth;
const absoluteRange = { const absoluteRange: RawTimeRange = {
from: parseDate(range.from, false), from: parseDate(range.from, false),
to: parseDate(range.to, true), to: parseDate(range.to, true),
}; };
@ -486,7 +474,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
]; ];
// Clone range for query request // Clone range for query request
const queryRange: Range = { ...range }; const queryRange: RawTimeRange = { ...range };
return { return {
interval, interval,
@ -584,13 +572,28 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}); });
} }
failQueryTransaction(transactionId: string, error: string, datasourceId: string) { failQueryTransaction(transactionId: string, response: any, datasourceId: string) {
const { datasource } = this.state; const { datasource } = this.state;
if (datasource.meta.id !== datasourceId) { if (datasource.meta.id !== datasourceId) {
// Navigated away, queries did not matter // Navigated away, queries did not matter
return; return;
} }
console.error(response);
let error: string | JSX.Element = response;
if (response.data) {
error = response.data.error;
if (response.data.response) {
error = (
<>
<span>{response.data.error}</span>
<details>{response.data.response}</details>
</>
);
}
}
this.setState(state => { this.setState(state => {
// Transaction might have been discarded // Transaction might have been discarded
if (!state.queryTransactions.find(qt => qt.id === transactionId)) { if (!state.queryTransactions.find(qt => qt.id === transactionId)) {
@ -637,9 +640,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
this.setState({ graphRange: transaction.options.range }); this.setState({ graphRange: transaction.options.range });
} catch (response) { } catch (response) {
console.error(response); this.failQueryTransaction(transaction.id, response, datasourceId);
const queryError = response.data ? response.data.error : response;
this.failQueryTransaction(transaction.id, queryError, datasourceId);
} }
} else { } else {
this.discardTransactions(rowIndex); this.discardTransactions(rowIndex);
@ -669,9 +670,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const results = res.data[0]; const results = res.data[0];
this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
} catch (response) { } catch (response) {
console.error(response); this.failQueryTransaction(transaction.id, response, datasourceId);
const queryError = response.data ? response.data.error : response;
this.failQueryTransaction(transaction.id, queryError, datasourceId);
} }
} else { } else {
this.discardTransactions(rowIndex); this.discardTransactions(rowIndex);
@ -697,9 +696,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const results = res.data; const results = res.data;
this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
} catch (response) { } catch (response) {
console.error(response); this.failQueryTransaction(transaction.id, response, datasourceId);
const queryError = response.data ? response.data.error : response;
this.failQueryTransaction(transaction.id, queryError, datasourceId);
} }
} else { } else {
this.discardTransactions(rowIndex); this.discardTransactions(rowIndex);
@ -751,15 +748,16 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const graphLoading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done); const graphLoading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done);
const tableLoading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done); const tableLoading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done);
const logsLoading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done); const logsLoading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done);
// TODO don't recreate those on each re-render
const graphResult = _.flatten( const graphResult = _.flatten(
queryTransactions.filter(qt => qt.resultType === 'Graph' && qt.done && qt.result).map(qt => qt.result) queryTransactions.filter(qt => qt.resultType === 'Graph' && qt.done && qt.result).map(qt => qt.result)
); );
const tableResult = mergeTablesIntoModel( const tableResult = mergeTablesIntoModel(
new TableModel(), new TableModel(),
...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done).map(qt => qt.result) ...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done && qt.result).map(qt => qt.result)
); );
const logsResult = _.flatten( const logsResult = _.flatten(
queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done).map(qt => qt.result) queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done && qt.result).map(qt => qt.result)
); );
const loading = queryTransactions.some(qt => !qt.done); const loading = queryTransactions.some(qt => !qt.done);
@ -868,6 +866,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
</div> </div>
<main className="m-t-2"> <main className="m-t-2">
<ErrorBoundary>
{supportsGraph && {supportsGraph &&
showingGraph && ( showingGraph && (
<Graph <Graph
@ -885,6 +884,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
</div> </div>
) : null} ) : null}
{supportsLogs && showingLogs ? <Logs data={logsResult} loading={logsLoading} /> : null} {supportsLogs && showingLogs ? <Logs data={logsResult} loading={logsLoading} /> : null}
</ErrorBoundary>
</main> </main>
</div> </div>
) : null} ) : null}

View File

@ -6,7 +6,7 @@ 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 { RawTimeRange } from 'app/types/series';
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';
@ -76,7 +76,7 @@ interface GraphProps {
height?: string; // e.g., '200px' height?: string; // e.g., '200px'
id?: string; id?: string;
loading?: boolean; loading?: boolean;
range: Range; range: RawTimeRange;
split?: boolean; split?: boolean;
size?: { width: number; height: number }; size?: { width: number; height: number };
} }

View File

@ -90,7 +90,7 @@ interface CascaderOption {
interface PromQueryFieldProps { interface PromQueryFieldProps {
datasource: any; datasource: any;
error?: string; error?: string | JSX.Element;
hint?: any; hint?: any;
history?: any[]; history?: any[];
initialQuery?: string | null; initialQuery?: string | null;

View File

@ -3,6 +3,7 @@ import moment from 'moment';
import * as dateMath from 'app/core/utils/datemath'; import * as dateMath from 'app/core/utils/datemath';
import * as rangeUtil from 'app/core/utils/rangeutil'; import * as rangeUtil from 'app/core/utils/rangeutil';
import { RawTimeRange } from 'app/types/series';
const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'; const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
export const DEFAULT_RANGE = { export const DEFAULT_RANGE = {
@ -10,77 +11,104 @@ export const DEFAULT_RANGE = {
to: 'now', to: 'now',
}; };
export function parseTime(value, isUtc = false, asString = false) { /**
* Return a human-editable string of either relative (inludes "now") or absolute local time (in the shape of DATE_FORMAT).
* @param value Epoch or relative time
*/
export function parseTime(value: string, isUtc = false): string {
if (value.indexOf('now') !== -1) { if (value.indexOf('now') !== -1) {
return value; return value;
} }
if (!isNaN(value)) { let time: any = value;
const epoch = parseInt(value, 10); // Possible epoch
const m = isUtc ? moment.utc(epoch) : moment(epoch); if (!isNaN(time)) {
return asString ? m.format(DATE_FORMAT) : m; time = parseInt(time, 10);
} }
return undefined; time = isUtc ? moment.utc(time) : moment(time);
return time.format(DATE_FORMAT);
} }
export default class TimePicker extends PureComponent<any, any> { interface TimePickerProps {
isOpen?: boolean;
isUtc?: boolean;
range?: RawTimeRange;
onChangeTime?: (Range) => void;
}
interface TimePickerState {
isOpen: boolean;
isUtc: boolean;
rangeString: string;
refreshInterval: string;
// Input-controlled text, keep these in a shape that is human-editable
fromRaw: string;
toRaw: string;
}
export default class TimePicker extends PureComponent<TimePickerProps, TimePickerState> {
dropdownEl: any; dropdownEl: any;
constructor(props) { constructor(props) {
super(props); super(props);
const fromRaw = props.range ? props.range.from : DEFAULT_RANGE.from; const from = props.range ? props.range.from : DEFAULT_RANGE.from;
const toRaw = props.range ? props.range.to : DEFAULT_RANGE.to; const to = props.range ? props.range.to : DEFAULT_RANGE.to;
// Ensure internal format
const fromRaw = parseTime(from, props.isUtc);
const toRaw = parseTime(to, props.isUtc);
const range = { const range = {
from: parseTime(fromRaw), from: fromRaw,
to: parseTime(toRaw), to: toRaw,
}; };
this.state = { this.state = {
fromRaw: parseTime(fromRaw, props.isUtc, true), fromRaw,
toRaw,
isOpen: props.isOpen, isOpen: props.isOpen,
isUtc: props.isUtc, isUtc: props.isUtc,
rangeString: rangeUtil.describeTimeRange(range), rangeString: rangeUtil.describeTimeRange(range),
refreshInterval: '', refreshInterval: '',
toRaw: parseTime(toRaw, props.isUtc, true),
}; };
} }
move(direction) { move(direction: number) {
const { onChangeTime } = this.props; const { onChangeTime } = this.props;
const { fromRaw, toRaw } = this.state; const { fromRaw, toRaw } = this.state;
const range = { const from = dateMath.parse(fromRaw, false);
from: dateMath.parse(fromRaw, false), const to = dateMath.parse(toRaw, true);
to: dateMath.parse(toRaw, true), const timespan = (to.valueOf() - from.valueOf()) / 2;
};
const timespan = (range.to.valueOf() - range.from.valueOf()) / 2; let nextTo, nextFrom;
let to, from;
if (direction === -1) { if (direction === -1) {
to = range.to.valueOf() - timespan; nextTo = to.valueOf() - timespan;
from = range.from.valueOf() - timespan; nextFrom = from.valueOf() - timespan;
} else if (direction === 1) { } else if (direction === 1) {
to = range.to.valueOf() + timespan; nextTo = to.valueOf() + timespan;
from = range.from.valueOf() + timespan; nextFrom = from.valueOf() + timespan;
if (to > Date.now() && range.to < Date.now()) { if (nextTo > Date.now() && to < Date.now()) {
to = Date.now(); nextTo = Date.now();
from = range.from.valueOf(); nextFrom = from.valueOf();
} }
} else { } else {
to = range.to.valueOf(); nextTo = to.valueOf();
from = range.from.valueOf(); nextFrom = from.valueOf();
} }
const rangeString = rangeUtil.describeTimeRange(range); const nextRange = {
// No need to convert to UTC again from: moment(nextFrom),
to = moment(to); to: moment(nextTo),
from = moment(from); };
this.setState( this.setState(
{ {
rangeString, rangeString: rangeUtil.describeTimeRange(nextRange),
fromRaw: from.format(DATE_FORMAT), fromRaw: nextRange.from.format(DATE_FORMAT),
toRaw: to.format(DATE_FORMAT), toRaw: nextRange.to.format(DATE_FORMAT),
}, },
() => { () => {
onChangeTime({ to, from }); onChangeTime(nextRange);
} }
); );
} }
@ -99,16 +127,19 @@ export default class TimePicker extends PureComponent<any, any> {
handleClickApply = () => { handleClickApply = () => {
const { onChangeTime } = this.props; const { onChangeTime } = this.props;
let range;
this.setState(
state => {
const { toRaw, fromRaw } = this.state; const { toRaw, fromRaw } = this.state;
const range = { range = {
from: dateMath.parse(fromRaw, false), from: dateMath.parse(fromRaw, false),
to: dateMath.parse(toRaw, true), to: dateMath.parse(toRaw, true),
}; };
const rangeString = rangeUtil.describeTimeRange(range); const rangeString = rangeUtil.describeTimeRange(range);
this.setState( return {
{
isOpen: false, isOpen: false,
rangeString, rangeString,
};
}, },
() => { () => {
if (onChangeTime) { if (onChangeTime) {

View File

@ -7,6 +7,7 @@ import { serializeStateToUrlParam, parseUrlState } from 'app/core/utils/explore'
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { ExploreState } from 'app/types/explore'; import { ExploreState } from 'app/types/explore';
import ErrorBoundary from './ErrorBoundary';
import Explore from './Explore'; import Explore from './Explore';
interface WrapperProps { interface WrapperProps {
@ -61,8 +62,10 @@ export class Wrapper extends Component<WrapperProps, WrapperState> {
const { split, splitState } = this.state; const { split, splitState } = this.state;
const urlStateLeft = parseUrlState(this.urlStates[STATE_KEY_LEFT]); const urlStateLeft = parseUrlState(this.urlStates[STATE_KEY_LEFT]);
const urlStateRight = parseUrlState(this.urlStates[STATE_KEY_RIGHT]); const urlStateRight = parseUrlState(this.urlStates[STATE_KEY_RIGHT]);
return ( return (
<div className="explore-wrapper"> <div className="explore-wrapper">
<ErrorBoundary>
<Explore <Explore
datasourceSrv={datasourceSrv} datasourceSrv={datasourceSrv}
onChangeSplit={this.onChangeSplit} onChangeSplit={this.onChangeSplit}
@ -72,7 +75,9 @@ export class Wrapper extends Component<WrapperProps, WrapperState> {
stateKey={STATE_KEY_LEFT} stateKey={STATE_KEY_LEFT}
urlState={urlStateLeft} urlState={urlStateLeft}
/> />
</ErrorBoundary>
{split && ( {split && (
<ErrorBoundary>
<Explore <Explore
datasourceSrv={datasourceSrv} datasourceSrv={datasourceSrv}
onChangeSplit={this.onChangeSplit} onChangeSplit={this.onChangeSplit}
@ -83,6 +88,7 @@ export class Wrapper extends Component<WrapperProps, WrapperState> {
stateKey={STATE_KEY_RIGHT} stateKey={STATE_KEY_RIGHT}
urlState={urlStateRight} urlState={urlStateRight}
/> />
</ErrorBoundary>
)} )}
</div> </div>
); );

View File

@ -1,5 +1,7 @@
import { Value } from 'slate'; import { Value } from 'slate';
import { RawTimeRange } from './series';
export interface CompletionItem { export interface CompletionItem {
/** /**
* The label of this completion item. By default * The label of this completion item. By default
@ -100,11 +102,6 @@ export interface TypeaheadOutput {
suggestions: CompletionItemGroup[]; suggestions: CompletionItemGroup[];
} }
export interface Range {
from: string;
to: string;
}
export interface Query { export interface Query {
query: string; query: string;
key?: string; key?: string;
@ -131,7 +128,7 @@ export interface QueryHint {
export interface QueryTransaction { export interface QueryTransaction {
id: string; id: string;
done: boolean; done: boolean;
error?: string; error?: string | JSX.Element;
hints?: QueryHint[]; hints?: QueryHint[];
latency: number; latency: number;
options: any; options: any;
@ -155,7 +152,7 @@ export interface ExploreState {
datasourceMissing: boolean; datasourceMissing: boolean;
datasourceName?: string; datasourceName?: string;
exploreDatasources: ExploreDatasource[]; exploreDatasources: ExploreDatasource[];
graphRange: Range; graphRange: RawTimeRange;
history: HistoryItem[]; history: HistoryItem[];
/** /**
* Initial rows of queries to push down the tree. * Initial rows of queries to push down the tree.
@ -167,7 +164,7 @@ export interface ExploreState {
* Hints gathered for the query row. * Hints gathered for the query row.
*/ */
queryTransactions: QueryTransaction[]; queryTransactions: QueryTransaction[];
range: Range; range: RawTimeRange;
showingGraph: boolean; showingGraph: boolean;
showingLogs: boolean; showingLogs: boolean;
showingTable: boolean; showingTable: boolean;
@ -179,7 +176,7 @@ export interface ExploreState {
export interface ExploreUrlState { export interface ExploreUrlState {
datasource: string; datasource: string;
queries: Query[]; queries: Query[];
range: Range; range: RawTimeRange;
} }
export type ResultType = 'Graph' | 'Logs' | 'Table'; export type ResultType = 'Graph' | 'Logs' | 'Table';

View File

@ -258,6 +258,11 @@
.prom-query-field-info { .prom-query-field-info {
margin: 0.25em 0.5em 0.5em; margin: 0.25em 0.5em 0.5em;
display: flex;
details {
margin-left: 1em;
}
} }
} }