Merge pull request #13844 from grafana/davkal/explore-empty-page

Explore: Pluggable components from datasource plugins
This commit is contained in:
David
2018-10-30 14:54:34 +01:00
committed by GitHub
15 changed files with 244 additions and 97 deletions

View File

@@ -0,0 +1,35 @@
import React from 'react';
const CHEAT_SHEET_ITEMS = [
{
title: 'Request Rate',
expression: 'rate(http_request_total[5m])',
label:
'Given an HTTP request counter, this query calculates the per-second average request rate over the last 5 minutes.',
},
{
title: '95th Percentile of Request Latencies',
expression: 'histogram_quantile(0.95, sum(rate(prometheus_http_request_duration_seconds_bucket[5m])) by (le))',
label: 'Calculates the 95th percentile of HTTP request rate over 5 minute windows.',
},
{
title: 'Alerts Firing',
expression: 'sort_desc(sum(sum_over_time(ALERTS{alertstate="firing"}[24h])) by (alertname))',
label: 'Sums up the alerts that have been firing over the last 24 hours.',
},
];
export default (props: any) => (
<div>
<h1>PromQL Cheat Sheet</h1>
{CHEAT_SHEET_ITEMS.map(item => (
<div className="cheat-sheet-item" key={item.expression}>
<div className="cheat-sheet-item__title">{item.title}</div>
<div className="cheat-sheet-item__expression" onClick={e => props.onClickQuery(item.expression)}>
<code>{item.expression}</code>
</div>
<div className="cheat-sheet-item__label">{item.label}</div>
</div>
))}
</div>
);

View File

@@ -0,0 +1,41 @@
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

@@ -0,0 +1,288 @@
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 'app/features/explore/utils/dom';
import BracesPlugin from 'app/features/explore/slate-plugins/braces';
import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
import TypeaheadField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/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 }: QueryFieldState): 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">
<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}
/>
{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

@@ -0,0 +1,60 @@
import React, { PureComponent } from 'react';
import classNames from 'classnames';
import PromCheatSheet from './PromCheatSheet';
const TAB_MENU_ITEMS = [
{
text: 'Start',
id: 'start',
icon: 'fa fa-rocket',
},
];
export default class PromStart extends PureComponent<any, { active: string }> {
state = {
active: 'start',
};
onClickTab = active => {
this.setState({ active });
};
render() {
const { active } = this.state;
const customCss = '';
return (
<div style={{ margin: '45px 0', border: '1px solid #ddd', borderRadius: 5 }}>
<div className="page-header-canvas">
<div className="page-container">
<div className="page-header">
<nav>
<ul className={`gf-tabs ${customCss}`}>
{TAB_MENU_ITEMS.map((tab, idx) => {
const tabClasses = classNames({
'gf-tabs-link': true,
active: tab.id === active,
});
return (
<li className="gf-tabs-item" key={tab.id}>
<a className={tabClasses} onClick={() => this.onClickTab(tab.id)}>
<i className={tab.icon} />
{tab.text}
</a>
</li>
);
})}
</ul>
</nav>
</div>
</div>
</div>
<div className="page-container page-body">
{active === 'start' && <PromCheatSheet onClickQuery={this.props.onClickQuery} />}
</div>
</div>
);
}
}

View File

@@ -2,6 +2,9 @@ import { PrometheusDatasource } from './datasource';
import { PrometheusQueryCtrl } from './query_ctrl';
import { PrometheusConfigCtrl } from './config_ctrl';
import PrometheusStartPage from './components/PromStart';
import PromQueryField from './components/PromQueryField';
class PrometheusAnnotationsQueryCtrl {
static templateUrl = 'partials/annotations.editor.html';
}
@@ -11,4 +14,6 @@ export {
PrometheusQueryCtrl as QueryCtrl,
PrometheusConfigCtrl as ConfigCtrl,
PrometheusAnnotationsQueryCtrl as AnnotationsQueryCtrl,
PromQueryField as ExploreQueryField,
PrometheusStartPage as ExploreStartPage,
};