mirror of
https://github.com/grafana/grafana.git
synced 2025-02-12 16:45:43 -06:00
Caused by the return and loading spinner icons having different widths. Used fa-fw helper class to ensure fixed width on icons. Also, made the spacing between icon and button label slightly smaller, as the rotated level-down icon used as return icon has significantly smaller width compared to the loading spinner and it looked oddly far from the label as compared to timepicker's icon
1039 lines
33 KiB
TypeScript
1039 lines
33 KiB
TypeScript
import React from 'react';
|
|
import { hot } from 'react-hot-loader';
|
|
import _ from 'lodash';
|
|
|
|
import { DataSource } from 'app/types/datasources';
|
|
import {
|
|
ExploreState,
|
|
ExploreUrlState,
|
|
QueryTransaction,
|
|
ResultType,
|
|
QueryHintGetter,
|
|
QueryHint,
|
|
} from 'app/types/explore';
|
|
import { TimeRange } from '@grafana/ui';
|
|
import { DataQuery } from 'app/types/series';
|
|
import store from 'app/core/store';
|
|
import {
|
|
DEFAULT_RANGE,
|
|
calculateResultsFromQueryTransactions,
|
|
ensureQueries,
|
|
getIntervals,
|
|
generateKey,
|
|
generateQueryKeys,
|
|
hasNonEmptyQuery,
|
|
makeTimeSeriesList,
|
|
updateHistory,
|
|
} from 'app/core/utils/explore';
|
|
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
|
|
import TableModel from 'app/core/table_model';
|
|
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
|
|
import { Emitter } from 'app/core/utils/emitter';
|
|
import * as dateMath from 'app/core/utils/datemath';
|
|
|
|
import Panel from './Panel';
|
|
import QueryRows from './QueryRows';
|
|
import Graph from './Graph';
|
|
import Logs from './Logs';
|
|
import Table from './Table';
|
|
import ErrorBoundary from './ErrorBoundary';
|
|
import { Alert } from './Error';
|
|
import TimePicker, { parseTime } from './TimePicker';
|
|
|
|
const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
|
|
|
|
interface ExploreProps {
|
|
datasourceSrv: DatasourceSrv;
|
|
onChangeSplit: (split: boolean, state?: ExploreState) => void;
|
|
onSaveState: (key: string, state: ExploreState) => void;
|
|
position: string;
|
|
split: boolean;
|
|
splitState?: ExploreState;
|
|
stateKey: string;
|
|
urlState: ExploreUrlState;
|
|
}
|
|
|
|
/**
|
|
* Explore provides an area for quick query iteration for a given datasource.
|
|
* Once a datasource is selected it populates the query section at the top.
|
|
* When queries are run, their results are being displayed in the main section.
|
|
* The datasource determines what kind of query editor it brings, and what kind
|
|
* of results viewers it supports.
|
|
*
|
|
* QUERY HANDLING
|
|
*
|
|
* TLDR: to not re-render Explore during edits, query editing is not "controlled"
|
|
* in a React sense: values need to be pushed down via `initialQueries`, while
|
|
* edits travel up via `this.modifiedQueries`.
|
|
*
|
|
* By default the query rows start without prior state: `initialQueries` will
|
|
* contain one empty DataQuery. While the user modifies the DataQuery, the
|
|
* modifications are being tracked in `this.modifiedQueries`, which need to be
|
|
* used whenever a query is sent to the datasource to reflect what the user sees
|
|
* on the screen. Query"react-popper": "^0.7.5", rows can be initialized or reset using `initialQueries`,
|
|
* by giving the respec"react-popper": "^0.7.5",tive row a new key. This wipes the old row and its state.
|
|
* This property is als"react-popper": "^0.7.5",o used to govern how many query rows there are (minimum 1).
|
|
*
|
|
* This flow makes sure that a query row can be arbitrarily complex without the
|
|
* fear of being wiped or re-initialized via props. The query row is free to keep
|
|
* its own state while the user edits or builds a query. Valid queries can be sent
|
|
* up to Explore via the `onChangeQuery` prop.
|
|
*
|
|
* DATASOURCE REQUESTS
|
|
*
|
|
* A click on Run Query creates transactions for all DataQueries for all expanded
|
|
* result viewers. New runs are discarding previous runs. Upon completion a transaction
|
|
* saves the result. The result viewers construct their data from the currently existing
|
|
* transactions.
|
|
*
|
|
* The result viewers determine some of the query options sent to the datasource, e.g.,
|
|
* `format`, to indicate eventual transformations by the datasources' result transformers.
|
|
*/
|
|
export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
|
el: any;
|
|
exploreEvents: Emitter;
|
|
/**
|
|
* Set via URL or local storage
|
|
*/
|
|
initialDatasource: string;
|
|
/**
|
|
* Current query expressions of the rows including their modifications, used for running queries.
|
|
* Not kept in component state to prevent edit-render roundtrips.
|
|
*/
|
|
modifiedQueries: DataQuery[];
|
|
/**
|
|
* Local ID cache to compare requested vs selected datasource
|
|
*/
|
|
requestedDatasourceId: string;
|
|
scanTimer: NodeJS.Timer;
|
|
/**
|
|
* Timepicker to control scanning
|
|
*/
|
|
timepickerRef: React.RefObject<TimePicker>;
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
const splitState: ExploreState = props.splitState;
|
|
let initialQueries: DataQuery[];
|
|
if (splitState) {
|
|
// Split state overrides everything
|
|
this.state = splitState;
|
|
initialQueries = splitState.initialQueries;
|
|
} else {
|
|
const { datasource, queries, range } = props.urlState as ExploreUrlState;
|
|
const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY);
|
|
initialQueries = ensureQueries(queries);
|
|
const initialRange = { from: parseTime(range.from), to: parseTime(range.to) } || { ...DEFAULT_RANGE };
|
|
// Millies step for helper bar charts
|
|
const initialGraphInterval = 15 * 1000;
|
|
this.state = {
|
|
datasource: null,
|
|
datasourceError: null,
|
|
datasourceLoading: null,
|
|
datasourceMissing: false,
|
|
exploreDatasources: [],
|
|
graphInterval: initialGraphInterval,
|
|
graphResult: [],
|
|
initialDatasource,
|
|
initialQueries,
|
|
history: [],
|
|
logsResult: null,
|
|
queryTransactions: [],
|
|
range: initialRange,
|
|
scanning: false,
|
|
showingGraph: true,
|
|
showingLogs: true,
|
|
showingStartPage: false,
|
|
showingTable: true,
|
|
supportsGraph: null,
|
|
supportsLogs: null,
|
|
supportsTable: null,
|
|
tableResult: new TableModel(),
|
|
};
|
|
}
|
|
this.modifiedQueries = initialQueries.slice();
|
|
this.exploreEvents = new Emitter();
|
|
this.timepickerRef = React.createRef();
|
|
}
|
|
|
|
async componentDidMount() {
|
|
const { datasourceSrv } = this.props;
|
|
const { initialDatasource } = this.state;
|
|
if (!datasourceSrv) {
|
|
throw new Error('No datasource service passed as props.');
|
|
}
|
|
|
|
const datasources = datasourceSrv.getExternal();
|
|
const exploreDatasources = datasources.map(ds => ({
|
|
value: ds.name,
|
|
name: ds.name,
|
|
meta: ds.meta,
|
|
}));
|
|
|
|
if (datasources.length > 0) {
|
|
this.setState({ datasourceLoading: true, exploreDatasources });
|
|
// Priority for datasource preselection: URL, localstorage, default datasource
|
|
let datasource;
|
|
if (initialDatasource) {
|
|
datasource = await datasourceSrv.get(initialDatasource);
|
|
} else {
|
|
datasource = await datasourceSrv.get();
|
|
}
|
|
await this.setDatasource(datasource);
|
|
} else {
|
|
this.setState({ datasourceMissing: true });
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.exploreEvents.removeAllListeners();
|
|
clearTimeout(this.scanTimer);
|
|
}
|
|
|
|
async setDatasource(datasource: any, origin?: DataSource) {
|
|
const { initialQueries, range } = this.state;
|
|
|
|
const supportsGraph = datasource.meta.metrics;
|
|
const supportsLogs = datasource.meta.logs;
|
|
const supportsTable = datasource.meta.tables;
|
|
const datasourceId = datasource.meta.id;
|
|
let datasourceError = null;
|
|
|
|
// Keep ID to track selection
|
|
this.requestedDatasourceId = datasourceId;
|
|
|
|
try {
|
|
const testResult = await datasource.testDatasource();
|
|
datasourceError = testResult.status === 'success' ? null : testResult.message;
|
|
} catch (error) {
|
|
datasourceError = (error && error.statusText) || 'Network error';
|
|
}
|
|
|
|
if (datasourceId !== this.requestedDatasourceId) {
|
|
// User already changed datasource again, discard results
|
|
return;
|
|
}
|
|
|
|
const historyKey = `grafana.explore.history.${datasourceId}`;
|
|
const history = store.getObject(historyKey, []);
|
|
|
|
if (datasource.init) {
|
|
datasource.init();
|
|
}
|
|
|
|
// Check if queries can be imported from previously selected datasource
|
|
let modifiedQueries = this.modifiedQueries;
|
|
if (origin) {
|
|
if (origin.meta.id === datasource.meta.id) {
|
|
// Keep same queries if same type of datasource
|
|
modifiedQueries = [...this.modifiedQueries];
|
|
} else if (datasource.importQueries) {
|
|
// Datasource-specific importers
|
|
modifiedQueries = await datasource.importQueries(this.modifiedQueries, origin.meta);
|
|
} else {
|
|
// Default is blank queries
|
|
modifiedQueries = ensureQueries();
|
|
}
|
|
}
|
|
|
|
// Reset edit state with new queries
|
|
const nextQueries = initialQueries.map((q, i) => ({
|
|
...modifiedQueries[i],
|
|
...generateQueryKeys(i),
|
|
}));
|
|
this.modifiedQueries = modifiedQueries;
|
|
|
|
// Custom components
|
|
const StartPage = datasource.pluginExports.ExploreStartPage;
|
|
|
|
// Calculate graph bucketing interval
|
|
const graphInterval = getIntervals(range, datasource, this.el ? this.el.offsetWidth : 0).intervalMs;
|
|
|
|
this.setState(
|
|
{
|
|
StartPage,
|
|
datasource,
|
|
datasourceError,
|
|
graphInterval,
|
|
history,
|
|
supportsGraph,
|
|
supportsLogs,
|
|
supportsTable,
|
|
datasourceLoading: false,
|
|
initialDatasource: datasource.name,
|
|
initialQueries: nextQueries,
|
|
logsHighlighterExpressions: undefined,
|
|
showingStartPage: Boolean(StartPage),
|
|
},
|
|
() => {
|
|
if (datasourceError === null) {
|
|
// Save last-used datasource
|
|
store.set(LAST_USED_DATASOURCE_KEY, datasource.name);
|
|
this.onSubmit();
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
getRef = el => {
|
|
this.el = el;
|
|
};
|
|
|
|
onAddQueryRow = index => {
|
|
// Local cache
|
|
this.modifiedQueries[index + 1] = { ...generateQueryKeys(index + 1) };
|
|
|
|
this.setState(state => {
|
|
const { initialQueries, queryTransactions } = state;
|
|
|
|
const nextQueries = [
|
|
...initialQueries.slice(0, index + 1),
|
|
{ ...this.modifiedQueries[index + 1] },
|
|
...initialQueries.slice(index + 1),
|
|
];
|
|
|
|
// Ongoing transactions need to update their row indices
|
|
const nextQueryTransactions = queryTransactions.map(qt => {
|
|
if (qt.rowIndex > index) {
|
|
return {
|
|
...qt,
|
|
rowIndex: qt.rowIndex + 1,
|
|
};
|
|
}
|
|
return qt;
|
|
});
|
|
|
|
return {
|
|
initialQueries: nextQueries,
|
|
logsHighlighterExpressions: undefined,
|
|
queryTransactions: nextQueryTransactions,
|
|
};
|
|
});
|
|
};
|
|
|
|
onChangeDatasource = async option => {
|
|
const origin = this.state.datasource;
|
|
this.setState({
|
|
datasource: null,
|
|
datasourceError: null,
|
|
datasourceLoading: true,
|
|
queryTransactions: [],
|
|
});
|
|
const datasourceName = option.value;
|
|
const datasource = await this.props.datasourceSrv.get(datasourceName);
|
|
this.setDatasource(datasource as any, origin);
|
|
};
|
|
|
|
onChangeQuery = (value: DataQuery, index: number, override?: boolean) => {
|
|
// Null value means reset
|
|
if (value === null) {
|
|
value = { ...generateQueryKeys(index) };
|
|
}
|
|
|
|
// Keep current value in local cache
|
|
this.modifiedQueries[index] = value;
|
|
|
|
if (override) {
|
|
this.setState(state => {
|
|
// Replace query row by injecting new key
|
|
const { initialQueries, queryTransactions } = state;
|
|
const query: DataQuery = {
|
|
...value,
|
|
...generateQueryKeys(index),
|
|
};
|
|
const nextQueries = [...initialQueries];
|
|
nextQueries[index] = query;
|
|
this.modifiedQueries = [...nextQueries];
|
|
|
|
// Discard ongoing transaction related to row query
|
|
const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
|
|
|
|
return {
|
|
initialQueries: nextQueries,
|
|
queryTransactions: nextQueryTransactions,
|
|
};
|
|
}, this.onSubmit);
|
|
} else if (this.state.datasource.getHighlighterExpression && this.modifiedQueries.length === 1) {
|
|
// Live preview of log search matches. Can only work on single row query for now
|
|
this.updateLogsHighlights(value);
|
|
}
|
|
};
|
|
|
|
onChangeTime = (nextRange: TimeRange, scanning?: boolean) => {
|
|
const range: TimeRange = {
|
|
...nextRange,
|
|
};
|
|
if (this.state.scanning && !scanning) {
|
|
this.onStopScanning();
|
|
}
|
|
this.setState({ range, scanning }, () => this.onSubmit());
|
|
};
|
|
|
|
onClickClear = () => {
|
|
this.onStopScanning();
|
|
this.modifiedQueries = ensureQueries();
|
|
this.setState(
|
|
prevState => ({
|
|
initialQueries: [...this.modifiedQueries],
|
|
queryTransactions: [],
|
|
showingStartPage: Boolean(prevState.StartPage),
|
|
}),
|
|
this.saveState
|
|
);
|
|
};
|
|
|
|
onClickCloseSplit = () => {
|
|
const { onChangeSplit } = this.props;
|
|
if (onChangeSplit) {
|
|
onChangeSplit(false);
|
|
}
|
|
};
|
|
|
|
onClickGraphButton = () => {
|
|
this.setState(
|
|
state => {
|
|
const showingGraph = !state.showingGraph;
|
|
let nextQueryTransactions = state.queryTransactions;
|
|
if (!showingGraph) {
|
|
// Discard transactions related to Graph query
|
|
nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Graph');
|
|
}
|
|
return { queryTransactions: nextQueryTransactions, showingGraph };
|
|
},
|
|
() => {
|
|
if (this.state.showingGraph) {
|
|
this.onSubmit();
|
|
}
|
|
}
|
|
);
|
|
};
|
|
|
|
onClickLogsButton = () => {
|
|
this.setState(
|
|
state => {
|
|
const showingLogs = !state.showingLogs;
|
|
let nextQueryTransactions = state.queryTransactions;
|
|
if (!showingLogs) {
|
|
// Discard transactions related to Logs query
|
|
nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Logs');
|
|
}
|
|
return { queryTransactions: nextQueryTransactions, showingLogs };
|
|
},
|
|
() => {
|
|
if (this.state.showingLogs) {
|
|
this.onSubmit();
|
|
}
|
|
}
|
|
);
|
|
};
|
|
|
|
// Use this in help pages to set page to a single query
|
|
onClickExample = (query: DataQuery) => {
|
|
const nextQueries = [{ ...query, ...generateQueryKeys() }];
|
|
this.modifiedQueries = [...nextQueries];
|
|
this.setState({ initialQueries: nextQueries }, this.onSubmit);
|
|
};
|
|
|
|
onClickSplit = () => {
|
|
const { onChangeSplit } = this.props;
|
|
if (onChangeSplit) {
|
|
const state = this.cloneState();
|
|
onChangeSplit(true, state);
|
|
}
|
|
};
|
|
|
|
onClickTableButton = () => {
|
|
this.setState(
|
|
state => {
|
|
const showingTable = !state.showingTable;
|
|
if (showingTable) {
|
|
return { showingTable, queryTransactions: state.queryTransactions };
|
|
}
|
|
|
|
// Toggle off needs discarding of table queries
|
|
const nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table');
|
|
const results = calculateResultsFromQueryTransactions(
|
|
nextQueryTransactions,
|
|
state.datasource,
|
|
state.graphInterval
|
|
);
|
|
|
|
return { ...results, queryTransactions: nextQueryTransactions, showingTable };
|
|
},
|
|
() => {
|
|
if (this.state.showingTable) {
|
|
this.onSubmit();
|
|
}
|
|
}
|
|
);
|
|
};
|
|
|
|
onClickLabel = (key: string, value: string) => {
|
|
this.onModifyQueries({ type: 'ADD_FILTER', key, value });
|
|
};
|
|
|
|
onModifyQueries = (action, index?: number) => {
|
|
const { datasource } = this.state;
|
|
if (datasource && datasource.modifyQuery) {
|
|
const preventSubmit = action.preventSubmit;
|
|
this.setState(
|
|
state => {
|
|
const { initialQueries, queryTransactions } = state;
|
|
let nextQueries: DataQuery[];
|
|
let nextQueryTransactions;
|
|
if (index === undefined) {
|
|
// Modify all queries
|
|
nextQueries = initialQueries.map((query, i) => ({
|
|
...datasource.modifyQuery(this.modifiedQueries[i], action),
|
|
...generateQueryKeys(i),
|
|
}));
|
|
// Discard all ongoing transactions
|
|
nextQueryTransactions = [];
|
|
} else {
|
|
// Modify query only at index
|
|
nextQueries = initialQueries.map((query, i) => {
|
|
// Synchronize all queries with local query cache to ensure consistency
|
|
// TODO still needed?
|
|
return i === index
|
|
? {
|
|
...datasource.modifyQuery(this.modifiedQueries[i], action),
|
|
...generateQueryKeys(i),
|
|
}
|
|
: query;
|
|
});
|
|
nextQueryTransactions = queryTransactions
|
|
// Consume the hint corresponding to the action
|
|
.map(qt => {
|
|
if (qt.hints != null && qt.rowIndex === index) {
|
|
qt.hints = qt.hints.filter(hint => hint.fix.action !== action);
|
|
}
|
|
return qt;
|
|
})
|
|
// Preserve previous row query transaction to keep results visible if next query is incomplete
|
|
.filter(qt => preventSubmit || qt.rowIndex !== index);
|
|
}
|
|
this.modifiedQueries = [...nextQueries];
|
|
return {
|
|
initialQueries: nextQueries,
|
|
queryTransactions: nextQueryTransactions,
|
|
};
|
|
},
|
|
// Accepting certain fixes do not result in a well-formed query which should not be submitted
|
|
!preventSubmit ? () => this.onSubmit() : null
|
|
);
|
|
}
|
|
};
|
|
|
|
onRemoveQueryRow = index => {
|
|
// Remove from local cache
|
|
this.modifiedQueries = [...this.modifiedQueries.slice(0, index), ...this.modifiedQueries.slice(index + 1)];
|
|
|
|
this.setState(
|
|
state => {
|
|
const { initialQueries, queryTransactions } = state;
|
|
if (initialQueries.length <= 1) {
|
|
return null;
|
|
}
|
|
// Remove row from react state
|
|
const nextQueries = [...initialQueries.slice(0, index), ...initialQueries.slice(index + 1)];
|
|
|
|
// Discard transactions related to row query
|
|
const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
|
|
const results = calculateResultsFromQueryTransactions(
|
|
nextQueryTransactions,
|
|
state.datasource,
|
|
state.graphInterval
|
|
);
|
|
|
|
return {
|
|
...results,
|
|
initialQueries: nextQueries,
|
|
logsHighlighterExpressions: undefined,
|
|
queryTransactions: nextQueryTransactions,
|
|
};
|
|
},
|
|
() => this.onSubmit()
|
|
);
|
|
};
|
|
|
|
onStartScanning = () => {
|
|
this.setState({ scanning: true }, this.scanPreviousRange);
|
|
};
|
|
|
|
scanPreviousRange = () => {
|
|
const scanRange = this.timepickerRef.current.move(-1, true);
|
|
this.setState({ scanRange });
|
|
};
|
|
|
|
onStopScanning = () => {
|
|
clearTimeout(this.scanTimer);
|
|
this.setState(state => {
|
|
const { queryTransactions } = state;
|
|
const nextQueryTransactions = queryTransactions.filter(qt => qt.scanning && !qt.done);
|
|
return { queryTransactions: nextQueryTransactions, scanning: false, scanRange: undefined };
|
|
});
|
|
};
|
|
|
|
onSubmit = () => {
|
|
const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state;
|
|
// Keep table queries first since they need to return quickly
|
|
if (showingTable && supportsTable) {
|
|
this.runQueries(
|
|
'Table',
|
|
{
|
|
format: 'table',
|
|
instant: true,
|
|
valueWithRefId: true,
|
|
},
|
|
data => data[0]
|
|
);
|
|
}
|
|
if (showingGraph && supportsGraph) {
|
|
this.runQueries(
|
|
'Graph',
|
|
{
|
|
format: 'time_series',
|
|
instant: false,
|
|
},
|
|
makeTimeSeriesList
|
|
);
|
|
}
|
|
if (showingLogs && supportsLogs) {
|
|
this.runQueries('Logs', { format: 'logs' });
|
|
}
|
|
this.saveState();
|
|
};
|
|
|
|
buildQueryOptions(query: DataQuery, queryOptions: { format: string; hinting?: boolean; instant?: boolean }) {
|
|
const { datasource, range } = this.state;
|
|
const { interval, intervalMs } = getIntervals(range, datasource, this.el.offsetWidth);
|
|
|
|
const configuredQueries = [
|
|
{
|
|
...query,
|
|
...queryOptions,
|
|
},
|
|
];
|
|
|
|
// Clone range for query request
|
|
// const queryRange: RawTimeRange = { ...range };
|
|
// const { from, to, raw } = this.timeSrv.timeRange();
|
|
// Most datasource is using `panelId + query.refId` for cancellation logic.
|
|
// Using `format` here because it relates to the view panel that the request is for.
|
|
// However, some datasources don't use `panelId + query.refId`, but only `panelId`.
|
|
// Therefore panel id has to be unique.
|
|
const panelId = `${queryOptions.format}-${query.key}`;
|
|
|
|
return {
|
|
interval,
|
|
intervalMs,
|
|
panelId,
|
|
targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key.
|
|
range: {
|
|
from: dateMath.parse(range.from, false),
|
|
to: dateMath.parse(range.to, true),
|
|
raw: range,
|
|
},
|
|
rangeRaw: range,
|
|
scopedVars: {
|
|
__interval: { text: interval, value: interval },
|
|
__interval_ms: { text: intervalMs, value: intervalMs },
|
|
},
|
|
};
|
|
}
|
|
|
|
startQueryTransaction(query: DataQuery, rowIndex: number, resultType: ResultType, options: any): QueryTransaction {
|
|
const queryOptions = this.buildQueryOptions(query, options);
|
|
const transaction: QueryTransaction = {
|
|
query,
|
|
resultType,
|
|
rowIndex,
|
|
id: generateKey(), // reusing for unique ID
|
|
done: false,
|
|
latency: 0,
|
|
options: queryOptions,
|
|
scanning: this.state.scanning,
|
|
};
|
|
|
|
// Using updater style because we might be modifying queryTransactions in quick succession
|
|
this.setState(state => {
|
|
const { queryTransactions } = state;
|
|
// Discarding existing transactions of same type
|
|
const remainingTransactions = queryTransactions.filter(
|
|
qt => !(qt.resultType === resultType && qt.rowIndex === rowIndex)
|
|
);
|
|
|
|
// Append new transaction
|
|
const nextQueryTransactions = [...remainingTransactions, transaction];
|
|
|
|
const results = calculateResultsFromQueryTransactions(
|
|
nextQueryTransactions,
|
|
state.datasource,
|
|
state.graphInterval
|
|
);
|
|
|
|
return {
|
|
...results,
|
|
queryTransactions: nextQueryTransactions,
|
|
showingStartPage: false,
|
|
graphInterval: queryOptions.intervalMs,
|
|
};
|
|
});
|
|
|
|
return transaction;
|
|
}
|
|
|
|
completeQueryTransaction(
|
|
transactionId: string,
|
|
result: any,
|
|
latency: number,
|
|
queries: DataQuery[],
|
|
datasourceId: string
|
|
) {
|
|
const { datasource } = this.state;
|
|
if (datasource.meta.id !== datasourceId) {
|
|
// Navigated away, queries did not matter
|
|
return;
|
|
}
|
|
|
|
this.setState(state => {
|
|
const { history, queryTransactions } = state;
|
|
let { scanning } = state;
|
|
|
|
// Transaction might have been discarded
|
|
const transaction = queryTransactions.find(qt => qt.id === transactionId);
|
|
if (!transaction) {
|
|
return null;
|
|
}
|
|
|
|
// Get query hints
|
|
let hints: QueryHint[];
|
|
if (datasource.getQueryHints as QueryHintGetter) {
|
|
hints = datasource.getQueryHints(transaction.query, result);
|
|
}
|
|
|
|
// Mark transactions as complete
|
|
const nextQueryTransactions = queryTransactions.map(qt => {
|
|
if (qt.id === transactionId) {
|
|
return {
|
|
...qt,
|
|
hints,
|
|
latency,
|
|
result,
|
|
done: true,
|
|
};
|
|
}
|
|
return qt;
|
|
});
|
|
|
|
const results = calculateResultsFromQueryTransactions(
|
|
nextQueryTransactions,
|
|
state.datasource,
|
|
state.graphInterval
|
|
);
|
|
|
|
const nextHistory = updateHistory(history, datasourceId, queries);
|
|
|
|
// Keep scanning for results if this was the last scanning transaction
|
|
if (scanning) {
|
|
if (_.size(result) === 0) {
|
|
const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done);
|
|
if (!other) {
|
|
this.scanTimer = setTimeout(this.scanPreviousRange, 1000);
|
|
}
|
|
} else {
|
|
// We can stop scanning if we have a result
|
|
scanning = false;
|
|
}
|
|
}
|
|
|
|
return {
|
|
...results,
|
|
scanning,
|
|
history: nextHistory,
|
|
queryTransactions: nextQueryTransactions,
|
|
};
|
|
});
|
|
}
|
|
|
|
failQueryTransaction(transactionId: string, response: any, datasourceId: string) {
|
|
const { datasource } = this.state;
|
|
if (datasource.meta.id !== datasourceId || response.cancelled) {
|
|
// Navigated away, queries did not matter
|
|
return;
|
|
}
|
|
|
|
console.error(response);
|
|
|
|
let error: string | JSX.Element;
|
|
if (response.data) {
|
|
if (typeof response.data === 'string') {
|
|
error = response.data;
|
|
} else if (response.data.error) {
|
|
error = response.data.error;
|
|
if (response.data.response) {
|
|
error = (
|
|
<>
|
|
<span>{response.data.error}</span>
|
|
<details>{response.data.response}</details>
|
|
</>
|
|
);
|
|
}
|
|
} else {
|
|
throw new Error('Could not handle error response');
|
|
}
|
|
} else if (response.message) {
|
|
error = response.message;
|
|
} else if (typeof response === 'string') {
|
|
error = response;
|
|
} else {
|
|
error = 'Unknown error during query transaction. Please check JS console logs.';
|
|
}
|
|
|
|
this.setState(state => {
|
|
// Transaction might have been discarded
|
|
if (!state.queryTransactions.find(qt => qt.id === transactionId)) {
|
|
return null;
|
|
}
|
|
|
|
// Mark transactions as complete
|
|
const nextQueryTransactions = state.queryTransactions.map(qt => {
|
|
if (qt.id === transactionId) {
|
|
return {
|
|
...qt,
|
|
error,
|
|
done: true,
|
|
};
|
|
}
|
|
return qt;
|
|
});
|
|
|
|
return {
|
|
queryTransactions: nextQueryTransactions,
|
|
};
|
|
});
|
|
}
|
|
|
|
async runQueries(resultType: ResultType, queryOptions: any, resultGetter?: any) {
|
|
const queries = [...this.modifiedQueries];
|
|
if (!hasNonEmptyQuery(queries)) {
|
|
this.setState({
|
|
queryTransactions: [],
|
|
});
|
|
return;
|
|
}
|
|
const { datasource } = this.state;
|
|
const datasourceId = datasource.meta.id;
|
|
// Run all queries concurrentlyso
|
|
queries.forEach(async (query, rowIndex) => {
|
|
const transaction = this.startQueryTransaction(query, rowIndex, resultType, queryOptions);
|
|
try {
|
|
const now = Date.now();
|
|
const res = await datasource.query(transaction.options);
|
|
this.exploreEvents.emit('data-received', res.data || []);
|
|
const latency = Date.now() - now;
|
|
const results = resultGetter ? resultGetter(res.data) : res.data;
|
|
this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
|
|
} catch (response) {
|
|
this.exploreEvents.emit('data-error', response);
|
|
this.failQueryTransaction(transaction.id, response, datasourceId);
|
|
}
|
|
});
|
|
}
|
|
|
|
updateLogsHighlights = _.debounce((value: DataQuery, index: number) => {
|
|
this.setState(state => {
|
|
const { datasource } = state;
|
|
if (datasource.getHighlighterExpression) {
|
|
const logsHighlighterExpressions = [state.datasource.getHighlighterExpression(value)];
|
|
return { logsHighlighterExpressions };
|
|
}
|
|
return null;
|
|
});
|
|
}, 500);
|
|
|
|
cloneState(): ExploreState {
|
|
// Copy state, but copy queries including modifications
|
|
return {
|
|
...this.state,
|
|
queryTransactions: [],
|
|
initialQueries: [...this.modifiedQueries],
|
|
};
|
|
}
|
|
|
|
saveState = () => {
|
|
const { stateKey, onSaveState } = this.props;
|
|
onSaveState(stateKey, this.cloneState());
|
|
};
|
|
|
|
render() {
|
|
const { position, split } = this.props;
|
|
const {
|
|
StartPage,
|
|
datasource,
|
|
datasourceError,
|
|
datasourceLoading,
|
|
datasourceMissing,
|
|
exploreDatasources,
|
|
graphResult,
|
|
history,
|
|
initialQueries,
|
|
logsHighlighterExpressions,
|
|
logsResult,
|
|
queryTransactions,
|
|
range,
|
|
scanning,
|
|
scanRange,
|
|
showingGraph,
|
|
showingLogs,
|
|
showingStartPage,
|
|
showingTable,
|
|
supportsGraph,
|
|
supportsLogs,
|
|
supportsTable,
|
|
tableResult,
|
|
} = this.state;
|
|
const graphHeight = showingGraph && showingTable ? '200px' : '400px';
|
|
const exploreClass = split ? 'explore explore-split' : 'explore';
|
|
const selectedDatasource = datasource ? exploreDatasources.find(d => d.name === datasource.name) : undefined;
|
|
const graphLoading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done);
|
|
const tableLoading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done);
|
|
const logsLoading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done);
|
|
const loading = queryTransactions.some(qt => !qt.done);
|
|
|
|
return (
|
|
<div className={exploreClass} ref={this.getRef}>
|
|
<div className="navbar">
|
|
{position === 'left' ? (
|
|
<div>
|
|
<a className="navbar-page-btn">
|
|
<i className="fa fa-rocket" />
|
|
Explore
|
|
</a>
|
|
</div>
|
|
) : (
|
|
<div className="navbar-buttons explore-first-button">
|
|
<button className="btn navbar-button" onClick={this.onClickCloseSplit}>
|
|
Close Split
|
|
</button>
|
|
</div>
|
|
)}
|
|
{!datasourceMissing ? (
|
|
<div className="navbar-buttons">
|
|
<DataSourcePicker
|
|
onChange={this.onChangeDatasource}
|
|
datasources={exploreDatasources}
|
|
current={selectedDatasource}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<div className="navbar__spacer" />
|
|
{position === 'left' && !split ? (
|
|
<div className="navbar-buttons">
|
|
<button className="btn navbar-button" onClick={this.onClickSplit}>
|
|
Split
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
<TimePicker ref={this.timepickerRef} range={range} onChangeTime={this.onChangeTime} />
|
|
<div className="navbar-buttons">
|
|
<button className="btn navbar-button navbar-button--no-icon" onClick={this.onClickClear}>
|
|
Clear All
|
|
</button>
|
|
</div>
|
|
<div className="navbar-buttons relative">
|
|
<button className="btn navbar-button navbar-button--primary" onClick={this.onSubmit}>
|
|
Run Query{' '}
|
|
{loading ? <i className="fa fa-spinner fa-fw fa-spin run-icon" /> : <i className="fa fa-level-down fa-fw run-icon" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{datasourceLoading ? <div className="explore-container">Loading datasource...</div> : null}
|
|
{datasourceMissing ? (
|
|
<div className="explore-container">Please add a datasource that supports Explore (e.g., Prometheus).</div>
|
|
) : null}
|
|
|
|
{datasourceError && (
|
|
<div className="explore-container">
|
|
<Alert message={`Error connecting to datasource: ${datasourceError}`} />
|
|
</div>
|
|
)}
|
|
|
|
{datasource && !datasourceError ? (
|
|
<div className="explore-container">
|
|
<QueryRows
|
|
datasource={datasource}
|
|
history={history}
|
|
initialQueries={initialQueries}
|
|
onAddQueryRow={this.onAddQueryRow}
|
|
onChangeQuery={this.onChangeQuery}
|
|
onClickHintFix={this.onModifyQueries}
|
|
onExecuteQuery={this.onSubmit}
|
|
onRemoveQueryRow={this.onRemoveQueryRow}
|
|
transactions={queryTransactions}
|
|
exploreEvents={this.exploreEvents}
|
|
range={range}
|
|
/>
|
|
<main className="m-t-2">
|
|
<ErrorBoundary>
|
|
{showingStartPage && <StartPage onClickExample={this.onClickExample} />}
|
|
{!showingStartPage && (
|
|
<>
|
|
{supportsGraph && (
|
|
<Panel
|
|
label="Graph"
|
|
isOpen={showingGraph}
|
|
loading={graphLoading}
|
|
onToggle={this.onClickGraphButton}
|
|
>
|
|
<Graph
|
|
data={graphResult}
|
|
height={graphHeight}
|
|
id={`explore-graph-${position}`}
|
|
onChangeTime={this.onChangeTime}
|
|
range={range}
|
|
split={split}
|
|
/>
|
|
</Panel>
|
|
)}
|
|
{supportsTable && (
|
|
<Panel
|
|
label="Table"
|
|
loading={tableLoading}
|
|
isOpen={showingTable}
|
|
onToggle={this.onClickTableButton}
|
|
>
|
|
<Table data={tableResult} loading={tableLoading} onClickCell={this.onClickLabel} />
|
|
</Panel>
|
|
)}
|
|
{supportsLogs && (
|
|
<Panel label="Logs" loading={logsLoading} isOpen={showingLogs} onToggle={this.onClickLogsButton}>
|
|
<Logs
|
|
data={logsResult}
|
|
key={logsResult.id}
|
|
highlighterExpressions={logsHighlighterExpressions}
|
|
loading={logsLoading}
|
|
position={position}
|
|
onChangeTime={this.onChangeTime}
|
|
onClickLabel={this.onClickLabel}
|
|
onStartScanning={this.onStartScanning}
|
|
onStopScanning={this.onStopScanning}
|
|
range={range}
|
|
scanning={scanning}
|
|
scanRange={scanRange}
|
|
/>
|
|
</Panel>
|
|
)}
|
|
</>
|
|
)}
|
|
</ErrorBoundary>
|
|
</main>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
export default hot(module)(Explore);
|