Pluggable components from datasource plugins

- when instantiating a datasource, the datasource service checks if the
  plugin module exports Explore components, and if so, attaches them to
  the datasource
- Explore component makes all major internal pluggable from a datasource
  `exploreComponents` property
- Moved Prometheus query field to promehteus datasource and registered
  it as an exported Explore component
- Added new Start page for Explore, also exported from the datasource
This commit is contained in:
David Kaltschmidt
2018-10-09 19:46:31 +02:00
parent b00e709aee
commit d0776937b5
15 changed files with 251 additions and 97 deletions

View File

@@ -4,6 +4,7 @@ import Select from 'react-select';
import _ from 'lodash';
import { ExploreState, ExploreUrlState, HistoryItem, Query, QueryTransaction, ResultType } from 'app/types/explore';
import { RawTimeRange } from 'app/types/series';
import kbn from 'app/core/utils/kbn';
import colors from 'app/core/utils/colors';
import store from 'app/core/store';
@@ -16,14 +17,13 @@ import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer'
import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
import DefaultQueryRows from './QueryRows';
import DefaultGraph from './Graph';
import DefaultLogs from './Logs';
import DefaultTable from './Table';
import ErrorBoundary from './ErrorBoundary';
import QueryRows from './QueryRows';
import Graph from './Graph';
import Logs from './Logs';
import Table from './Table';
import TimePicker from './TimePicker';
import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
import { RawTimeRange } from 'app/types/series';
const MAX_HISTORY_ITEMS = 100;
@@ -96,6 +96,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
initialQueries = ensureQueries(queries);
const initialRange = range || { ...DEFAULT_RANGE };
this.state = {
customComponents: {},
datasource: null,
datasourceError: null,
datasourceLoading: null,
@@ -176,8 +177,13 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
query: this.queryExpressions[i],
}));
const customComponents = {
...datasource.exploreComponents,
};
this.setState(
{
customComponents,
datasource,
datasourceError,
history,
@@ -330,6 +336,13 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
);
};
// Use this in help pages to set page to a single query
onClickQuery = query => {
const nextQueries = [{ query, key: generateQueryKey() }];
this.queryExpressions = nextQueries.map(q => q.query);
this.setState({ queries: nextQueries }, this.onSubmit);
};
onClickSplit = () => {
const { onChangeSplit } = this.props;
if (onChangeSplit) {
@@ -385,9 +398,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
q.query = this.queryExpressions[i];
return i === index
? {
key: generateQueryKey(index),
query: datasource.modifyQuery(q.query, action),
}
key: generateQueryKey(index),
query: datasource.modifyQuery(q.query, action),
}
: q;
});
nextQueryTransactions = queryTransactions
@@ -721,6 +734,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
render() {
const { position, split } = this.props;
const {
customComponents,
datasource,
datasourceError,
datasourceLoading,
@@ -760,6 +774,14 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done && qt.result).map(qt => qt.result)
);
const loading = queryTransactions.some(qt => !qt.done);
const showStartPages = queryTransactions.length === 0 && customComponents.StartPage;
// Custom components
const Graph = customComponents.Graph || DefaultGraph;
const Logs = customComponents.Logs || DefaultLogs;
const QueryRows = customComponents.QueryRows || DefaultQueryRows;
const StartPage = customComponents.StartPage;
const Table = customComponents.Table || DefaultTable;
return (
<div className={exploreClass} ref={this.getRef}>
@@ -772,12 +794,12 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
</a>
</div>
) : (
<div className="navbar-buttons explore-first-button">
<button className="btn navbar-button" onClick={this.onClickCloseSplit}>
Close Split
<div className="navbar-buttons explore-first-button">
<button className="btn navbar-button" onClick={this.onClickCloseSplit}>
Close Split
</button>
</div>
)}
</div>
)}
{!datasourceMissing ? (
<div className="navbar-buttons">
<Select
@@ -836,6 +858,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
{datasource && !datasourceError ? (
<div className="explore-container">
<QueryRows
customComponents={customComponents}
datasource={datasource}
history={history}
queries={queries}
@@ -847,43 +870,48 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
supportsLogs={supportsLogs}
transactions={queryTransactions}
/>
<div className="result-options">
{supportsGraph ? (
<button className={`btn toggle-btn ${graphButtonActive}`} onClick={this.onClickGraphButton}>
Graph
</button>
) : null}
{supportsTable ? (
<button className={`btn toggle-btn ${tableButtonActive}`} onClick={this.onClickTableButton}>
Table
</button>
) : null}
{supportsLogs ? (
<button className={`btn toggle-btn ${logsButtonActive}`} onClick={this.onClickLogsButton}>
Logs
</button>
) : null}
</div>
<main className="m-t-2">
<ErrorBoundary>
{supportsGraph &&
showingGraph && (
<Graph
data={graphResult}
height={graphHeight}
loading={graphLoading}
id={`explore-graph-${position}`}
range={graphRange}
split={split}
/>
)}
{supportsTable && showingTable ? (
<div className="panel-container m-t-2">
<Table data={tableResult} loading={tableLoading} onClickCell={this.onClickTableCell} />
</div>
) : null}
{supportsLogs && showingLogs ? <Logs data={logsResult} loading={logsLoading} /> : null}
{showStartPages && <StartPage onClickQuery={this.onClickQuery} />}
{!showStartPages && (
<>
<div className="result-options">
{supportsGraph ? (
<button className={`btn toggle-btn ${graphButtonActive}`} onClick={this.onClickGraphButton}>
Graph
</button>
) : null}
{supportsTable ? (
<button className={`btn toggle-btn ${tableButtonActive}`} onClick={this.onClickTableButton}>
Table
</button>
) : null}
{supportsLogs ? (
<button className={`btn toggle-btn ${logsButtonActive}`} onClick={this.onClickLogsButton}>
Logs
</button>
) : null}
</div>
{supportsGraph &&
showingGraph && (
<Graph
data={graphResult}
height={graphHeight}
loading={graphLoading}
id={`explore-graph-${position}`}
range={graphRange}
split={split}
/>
)}
{supportsTable && showingTable ? (
<div className="panel-container m-t-2">
<Table data={tableResult} loading={tableLoading} onClickCell={this.onClickTableCell} />
</div>
) : null}
{supportsLogs && showingLogs ? <Logs data={logsResult} loading={logsLoading} /> : null}
</>
)}
</ErrorBoundary>
</main>
</div>

View File

@@ -1,41 +0,0 @@
import { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
describe('groupMetricsByPrefix()', () => {
it('returns an empty group for no metrics', () => {
expect(groupMetricsByPrefix([])).toEqual([]);
});
it('returns options grouped by prefix', () => {
expect(groupMetricsByPrefix(['foo_metric'])).toMatchObject([
{
value: 'foo',
children: [
{
value: 'foo_metric',
},
],
},
]);
});
it('returns options without prefix as toplevel option', () => {
expect(groupMetricsByPrefix(['metric'])).toMatchObject([
{
value: 'metric',
},
]);
});
it('returns recording rules grouped separately', () => {
expect(groupMetricsByPrefix([':foo_metric:'])).toMatchObject([
{
value: RECORDING_RULES_GROUP,
children: [
{
value: ':foo_metric:',
},
],
},
]);
});
});

View File

@@ -1,294 +0,0 @@
import _ from 'lodash';
import React from 'react';
import Cascader from 'rc-cascader';
import PluginPrism from 'slate-prism';
import Prism from 'prismjs';
import { TypeaheadOutput } from 'app/types/explore';
// dom also includes Element polyfills
import { getNextCharacter, getPreviousCousin } from './utils/dom';
import BracesPlugin from './slate-plugins/braces';
import RunnerPlugin from './slate-plugins/runner';
import TypeaheadField, { TypeaheadInput, TypeaheadFieldState } from './QueryField';
const HISTOGRAM_GROUP = '__histograms__';
const METRIC_MARK = 'metric';
const PRISM_SYNTAX = 'promql';
export const RECORDING_RULES_GROUP = '__recording_rules__';
export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
// Filter out recording rules and insert as first option
const ruleRegex = /:\w+:/;
const ruleNames = metrics.filter(metric => ruleRegex.test(metric));
const rulesOption = {
label: 'Recording rules',
value: RECORDING_RULES_GROUP,
children: ruleNames
.slice()
.sort()
.map(name => ({ label: name, value: name })),
};
const options = ruleNames.length > 0 ? [rulesOption] : [];
const metricsOptions = _.chain(metrics)
.filter(metric => !ruleRegex.test(metric))
.groupBy(metric => metric.split(delimiter)[0])
.map((metricsForPrefix: string[], prefix: string): CascaderOption => {
const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
const children = prefixIsMetric ? [] : metricsForPrefix.sort().map(m => ({ label: m, value: m }));
return {
children,
label: prefix,
value: prefix,
};
})
.sortBy('label')
.value();
return [...options, ...metricsOptions];
}
export function willApplySuggestion(
suggestion: string,
{ typeaheadContext, typeaheadText }: TypeaheadFieldState
): string {
// Modify suggestion based on context
switch (typeaheadContext) {
case 'context-labels': {
const nextChar = getNextCharacter();
if (!nextChar || nextChar === '}' || nextChar === ',') {
suggestion += '=';
}
break;
}
case 'context-label-values': {
// Always add quotes and remove existing ones instead
if (!typeaheadText.match(/^(!?=~?"|")/)) {
suggestion = `"${suggestion}`;
}
if (getNextCharacter() !== '"') {
suggestion = `${suggestion}"`;
}
break;
}
default:
}
return suggestion;
}
interface CascaderOption {
label: string;
value: string;
children?: CascaderOption[];
disabled?: boolean;
}
interface PromQueryFieldProps {
datasource: any;
error?: string | JSX.Element;
hint?: any;
history?: any[];
initialQuery?: string | null;
metricsByPrefix?: CascaderOption[];
onClickHintFix?: (action: any) => void;
onPressEnter?: () => void;
onQueryChange?: (value: string, override?: boolean) => void;
supportsLogs?: boolean; // To be removed after Logging gets its own query field
}
interface PromQueryFieldState {
logLabelOptions: any[];
metricsOptions: any[];
metricsByPrefix: CascaderOption[];
syntaxLoaded: boolean;
}
class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> {
plugins: any[];
languageProvider: any;
constructor(props: PromQueryFieldProps, context) {
super(props, context);
if (props.datasource.languageProvider) {
this.languageProvider = props.datasource.languageProvider;
}
this.plugins = [
BracesPlugin(),
RunnerPlugin({ handler: props.onPressEnter }),
PluginPrism({
onlyIn: node => node.type === 'code_block',
getSyntax: node => 'promql',
}),
];
this.state = {
logLabelOptions: [],
metricsByPrefix: [],
metricsOptions: [],
syntaxLoaded: false,
};
}
componentDidMount() {
if (this.languageProvider) {
this.languageProvider.start().then(() => this.onReceiveMetrics());
}
}
onChangeLogLabels = (values: string[], selectedOptions: CascaderOption[]) => {
let query;
if (selectedOptions.length === 1) {
if (selectedOptions[0].children.length === 0) {
query = selectedOptions[0].value;
} else {
// Ignore click on group
return;
}
} else {
const key = selectedOptions[0].value;
const value = selectedOptions[1].value;
query = `{${key}="${value}"}`;
}
this.onChangeQuery(query, true);
};
onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => {
let query;
if (selectedOptions.length === 1) {
if (selectedOptions[0].children.length === 0) {
query = selectedOptions[0].value;
} else {
// Ignore click on group
return;
}
} else {
const prefix = selectedOptions[0].value;
const metric = selectedOptions[1].value;
if (prefix === HISTOGRAM_GROUP) {
query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;
} else {
query = metric;
}
}
this.onChangeQuery(query, true);
};
onChangeQuery = (value: string, override?: boolean) => {
// Send text change to parent
const { onQueryChange } = this.props;
if (onQueryChange) {
onQueryChange(value, override);
}
};
onClickHintFix = () => {
const { hint, onClickHintFix } = this.props;
if (onClickHintFix && hint && hint.fix) {
onClickHintFix(hint.fix.action);
}
};
onReceiveMetrics = () => {
const { histogramMetrics, metrics } = this.languageProvider;
if (!metrics) {
return;
}
Prism.languages[PRISM_SYNTAX] = this.languageProvider.getSyntax();
Prism.languages[PRISM_SYNTAX][METRIC_MARK] = {
alias: 'variable',
pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`),
};
// Build metrics tree
const metricsByPrefix = groupMetricsByPrefix(metrics);
const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
const metricsOptions = [
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
...metricsByPrefix,
];
this.setState({ metricsOptions, syntaxLoaded: true });
};
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
if (!this.languageProvider) {
return { suggestions: [] };
}
const { history } = this.props;
const { prefix, text, value, wrapperNode } = typeahead;
// Get DOM-dependent context
const wrapperClasses = Array.from(wrapperNode.classList);
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
const labelKey = labelKeyNode && labelKeyNode.textContent;
const nextChar = getNextCharacter();
const result = this.languageProvider.provideCompletionItems(
{ text, value, prefix, wrapperClasses, labelKey },
{ history }
);
console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
return result;
};
render() {
const { error, hint, initialQuery, supportsLogs } = this.props;
const { logLabelOptions, metricsOptions, syntaxLoaded } = this.state;
const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
return (
<div className="prom-query-field">
<div className="prom-query-field-tools">
{supportsLogs ? (
<Cascader options={logLabelOptions} onChange={this.onChangeLogLabels}>
<button className="btn navbar-button navbar-button--tight">Log labels</button>
</Cascader>
) : (
<Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
<button className="btn navbar-button navbar-button--tight">Metrics</button>
</Cascader>
)}
</div>
<div className="prom-query-field-wrapper">
<div className="slate-query-field-wrapper">
<TypeaheadField
additionalPlugins={this.plugins}
cleanText={cleanText}
initialValue={initialQuery}
onTypeahead={this.onTypeahead}
onWillApplySuggestion={willApplySuggestion}
onValueChanged={this.onChangeQuery}
placeholder="Enter a PromQL query"
portalOrigin="prometheus"
syntaxLoaded={syntaxLoaded}
/>
</div>
{error ? <div className="prom-query-field-info text-error">{error}</div> : null}
{hint ? (
<div className="prom-query-field-info text-warning">
{hint.label}{' '}
{hint.fix ? (
<a className="text-link muted" onClick={this.onClickHintFix}>
{hint.fix.label}
</a>
) : null}
</div>
) : null}
</div>
</div>
);
}
}
export default PromQueryField;

View File

@@ -72,7 +72,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
this.placeholdersBuffer = new PlaceholdersBuffer(props.initialValue || '');
// Base plugins
this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins];
this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins].filter(p => p);
this.state = {
suggestions: [],
@@ -434,19 +434,21 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
render() {
return (
<div className="slate-query-field">
{this.renderMenu()}
<Editor
autoCorrect={false}
onBlur={this.handleBlur}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
onFocus={this.handleFocus}
placeholder={this.props.placeholder}
plugins={this.plugins}
spellCheck={false}
value={this.state.value}
/>
<div className="slate-query-field-wrapper">
<div className="slate-query-field">
{this.renderMenu()}
<Editor
autoCorrect={false}
onBlur={this.handleBlur}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
onFocus={this.handleFocus}
placeholder={this.props.placeholder}
plugins={this.plugins}
spellCheck={false}
value={this.state.value}
/>
</div>
</div>
);
}

View File

@@ -2,9 +2,8 @@ import React, { PureComponent } from 'react';
import { QueryTransaction, HistoryItem, Query, QueryHint } from 'app/types/explore';
// TODO make this datasource-plugin-dependent
import QueryField from './PromQueryField';
import QueryTransactions from './QueryTransactions';
import DefaultQueryField from './QueryField';
import QueryTransactionStatus from './QueryTransactionStatus';
function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
@@ -24,6 +23,7 @@ interface QueryRowEventHandlers {
interface QueryRowCommonProps {
className?: string;
customComponents: any;
datasource: any;
history: HistoryItem[];
// Temporarily
@@ -37,7 +37,7 @@ type QueryRowProps = QueryRowCommonProps &
query: string;
};
class QueryRow extends PureComponent<QueryRowProps> {
class DefaultQueryRow extends PureComponent<QueryRowProps> {
onChangeQuery = (value, override?: boolean) => {
const { index, onChangeQuery } = this.props;
if (onChangeQuery) {
@@ -78,14 +78,15 @@ class QueryRow extends PureComponent<QueryRowProps> {
};
render() {
const { datasource, history, query, supportsLogs, transactions } = this.props;
const { customComponents, datasource, history, query, supportsLogs, transactions } = this.props;
const transactionWithError = transactions.find(t => t.error !== undefined);
const hint = getFirstHintFromTransactions(transactions);
const queryError = transactionWithError ? transactionWithError.error : null;
const QueryField = customComponents.QueryField || DefaultQueryField;
return (
<div className="query-row">
<div className="query-row-status">
<QueryTransactions transactions={transactions} />
<QueryTransactionStatus transactions={transactions} />
</div>
<div className="query-row-field">
<QueryField
@@ -123,12 +124,14 @@ type QueryRowsProps = QueryRowCommonProps &
export default class QueryRows extends PureComponent<QueryRowsProps> {
render() {
const { className = '', queries, transactions, ...handlers } = this.props;
const { className = '', customComponents, queries, transactions, ...handlers } = this.props;
const QueryRow = customComponents.QueryRow || DefaultQueryRow;
return (
<div className={className}>
{queries.map((q, index) => (
<QueryRow
key={q.key}
customComponents={customComponents}
index={index}
query={q.query}
transactions={transactions.filter(t => t.rowIndex === index)}

View File

@@ -1,17 +1,17 @@
import React, { PureComponent } from 'react';
import { QueryTransaction as QueryTransactionModel } from 'app/types/explore';
import { QueryTransaction } from 'app/types/explore';
import ElapsedTime from './ElapsedTime';
function formatLatency(value) {
return `${(value / 1000).toFixed(1)}s`;
}
interface QueryTransactionProps {
transaction: QueryTransactionModel;
interface QueryTransactionStatusItemProps {
transaction: QueryTransaction;
}
class QueryTransaction extends PureComponent<QueryTransactionProps> {
class QueryTransactionStatusItem extends PureComponent<QueryTransactionStatusItemProps> {
render() {
const { transaction } = this.props;
const className = transaction.done ? 'query-transaction' : 'query-transaction query-transaction--loading';
@@ -26,16 +26,16 @@ class QueryTransaction extends PureComponent<QueryTransactionProps> {
}
}
interface QueryTransactionsProps {
transactions: QueryTransactionModel[];
interface QueryTransactionStatusProps {
transactions: QueryTransaction[];
}
export default class QueryTransactions extends PureComponent<QueryTransactionsProps> {
export default class QueryTransactionStatus extends PureComponent<QueryTransactionStatusProps> {
render() {
const { transactions } = this.props;
return (
<div className="query-transactions">
{transactions.map((t, i) => <QueryTransaction key={`${t.query}:${t.resultType}`} transaction={t} />)}
{transactions.map((t, i) => <QueryTransactionStatusItem key={`${t.query}:${t.resultType}`} transaction={t} />)}
</div>
);
}

View File

@@ -64,6 +64,7 @@ export class DatasourceSrv {
const instance = this.$injector.instantiate(plugin.Datasource, { instanceSettings: dsConfig });
instance.meta = pluginDef;
instance.name = name;
instance.exploreComponents = plugin.ExploreComponents;
this.datasources[name] = instance;
deferred.resolve(instance);
})