diff --git a/public/app/containers/Explore/PromQueryField.jest.tsx b/public/app/containers/Explore/PromQueryField.jest.tsx
index 8d2903cb2c2..cd0d940961e 100644
--- a/public/app/containers/Explore/PromQueryField.jest.tsx
+++ b/public/app/containers/Explore/PromQueryField.jest.tsx
@@ -1,11 +1,12 @@
import React from 'react';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
-
-Enzyme.configure({ adapter: new Adapter() });
+import Plain from 'slate-plain-serializer';
import PromQueryField from './PromQueryField';
+Enzyme.configure({ adapter: new Adapter() });
+
describe('PromQueryField typeahead handling', () => {
const defaultProps = {
request: () => ({ data: { data: [] } }),
@@ -59,20 +60,35 @@ describe('PromQueryField typeahead handling', () => {
describe('label suggestions', () => {
it('returns default label suggestions on label context and no metric', () => {
const instance = shallow().instance() as PromQueryField;
- const result = instance.getTypeahead({ text: 'j', prefix: 'j', wrapperClasses: ['context-labels'] });
+ const value = Plain.deserialize('{}');
+ const range = value.selection.merge({
+ anchorOffset: 1,
+ });
+ const valueWithSelection = value.change().select(range).value;
+ const result = instance.getTypeahead({
+ text: '',
+ prefix: '',
+ wrapperClasses: ['context-labels'],
+ value: valueWithSelection,
+ });
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]);
});
it('returns label suggestions on label context and metric', () => {
const instance = shallow(
-
+
).instance() as PromQueryField;
+ const value = Plain.deserialize('metric{}');
+ const range = value.selection.merge({
+ anchorOffset: 7,
+ });
+ const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
- text: 'job',
- prefix: 'job',
+ text: '',
+ prefix: '',
wrapperClasses: ['context-labels'],
- metric: 'foo',
+ value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
@@ -80,13 +96,18 @@ describe('PromQueryField typeahead handling', () => {
it('returns a refresher on label context and unavailable metric', () => {
const instance = shallow(
-
+
).instance() as PromQueryField;
+ const value = Plain.deserialize('metric{}');
+ const range = value.selection.merge({
+ anchorOffset: 7,
+ });
+ const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
- text: 'job',
- prefix: 'job',
+ text: '',
+ prefix: '',
wrapperClasses: ['context-labels'],
- metric: 'xxx',
+ value: valueWithSelection,
});
expect(result.context).toBeUndefined();
expect(result.refresher).toBeInstanceOf(Promise);
@@ -95,28 +116,61 @@ describe('PromQueryField typeahead handling', () => {
it('returns label values on label context when given a metric and a label key', () => {
const instance = shallow(
-
+
).instance() as PromQueryField;
+ const value = Plain.deserialize('metric{bar=ba}');
+ const range = value.selection.merge({
+ anchorOffset: 13,
+ });
+ const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '=ba',
prefix: 'ba',
wrapperClasses: ['context-labels'],
- metric: 'foo',
labelKey: 'bar',
+ value: valueWithSelection,
});
expect(result.context).toBe('context-label-values');
- expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values' }]);
+ expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]);
});
- it('returns label suggestions on aggregation context and metric', () => {
+ it('returns label suggestions on aggregation context and metric w/ selector', () => {
const instance = shallow(
-
+
).instance() as PromQueryField;
+ const value = Plain.deserialize('sum(metric{foo="xx"}) by ()');
+ const range = value.selection.merge({
+ anchorOffset: 26,
+ });
+ const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
- text: 'job',
- prefix: 'job',
+ text: '',
+ prefix: '',
wrapperClasses: ['context-aggregation'],
- metric: 'foo',
+ value: valueWithSelection,
+ });
+ expect(result.context).toBe('context-aggregation');
+ expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
+ });
+
+ it('returns label suggestions on aggregation context and metric w/o selector', () => {
+ const instance = shallow(
+
+ ).instance() as PromQueryField;
+ const value = Plain.deserialize('sum(metric) by ()');
+ const range = value.selection.merge({
+ anchorOffset: 16,
+ });
+ const valueWithSelection = value.change().select(range).value;
+ const result = instance.getTypeahead({
+ text: '',
+ prefix: '',
+ wrapperClasses: ['context-aggregation'],
+ value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
diff --git a/public/app/containers/Explore/PromQueryField.tsx b/public/app/containers/Explore/PromQueryField.tsx
index eb8fc25c67f..c6119cc9d0f 100644
--- a/public/app/containers/Explore/PromQueryField.tsx
+++ b/public/app/containers/Explore/PromQueryField.tsx
@@ -1,12 +1,13 @@
import _ from 'lodash';
import React from 'react';
+import { Value } from 'slate';
// dom also includes Element polyfills
import { getNextCharacter, getPreviousCousin } from './utils/dom';
import PluginPrism, { setPrismTokens } from './slate-plugins/prism/index';
import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql';
import RunnerPlugin from './slate-plugins/runner';
-import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
+import { processLabels, RATE_RANGES, cleanText, getCleanSelector } from './utils/prometheus';
import TypeaheadField, {
Suggestion,
@@ -16,7 +17,8 @@ import TypeaheadField, {
TypeaheadOutput,
} from './QueryField';
-const EMPTY_METRIC = '';
+const DEFAULT_KEYS = ['job', 'instance'];
+const EMPTY_SELECTOR = '{}';
const METRIC_MARK = 'metric';
const PRISM_LANGUAGE = 'promql';
@@ -77,8 +79,8 @@ interface PromTypeaheadInput {
text: string;
prefix: string;
wrapperClasses: string[];
- metric?: string;
labelKey?: string;
+ value?: Value;
}
class PromQueryField extends React.Component {
@@ -119,25 +121,23 @@ class PromQueryField extends React.Component {
- const { editorNode, prefix, text, wrapperNode } = typeahead;
+ const { prefix, text, value, wrapperNode } = typeahead;
// Get DOM-dependent context
const wrapperClasses = Array.from(wrapperNode.classList);
- // Take first metric as lucky guess
- const metricNode = editorNode.querySelector(`.${METRIC_MARK}`);
- const metric = metricNode && metricNode.textContent;
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
const labelKey = labelKeyNode && labelKeyNode.textContent;
+ const nextChar = getNextCharacter();
- const result = this.getTypeahead({ text, prefix, wrapperClasses, metric, labelKey });
+ const result = this.getTypeahead({ text, value, prefix, wrapperClasses, labelKey });
- console.log('handleTypeahead', wrapperClasses, text, prefix, result.context);
+ console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
return result;
};
// Keep this DOM-free for testing
- getTypeahead({ prefix, wrapperClasses, metric, text }: PromTypeaheadInput): TypeaheadOutput {
+ getTypeahead({ prefix, wrapperClasses, text }: PromTypeaheadInput): TypeaheadOutput {
// Determine candidates by CSS context
if (_.includes(wrapperClasses, 'context-range')) {
// Suggestions for metric[|]
@@ -145,12 +145,11 @@ class PromQueryField extends React.Component = null;
const suggestions: SuggestionGroup[] = [];
- const labelKeys = this.state.labelKeys[metric];
+
+ // sum(foo{bar="1"}) by (|)
+ const line = value.anchorBlock.getText();
+ const cursorOffset: number = value.anchorOffset;
+ // sum(foo{bar="1"}) by (
+ const leftSide = line.slice(0, cursorOffset);
+ const openParensAggregationIndex = leftSide.lastIndexOf('(');
+ const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('(');
+ const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
+ // foo{bar="1"}
+ const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
+ const selector = getCleanSelector(selectorString, selectorString.length - 2);
+
+ const labelKeys = this.state.labelKeys[selector];
if (labelKeys) {
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
} else {
- refresher = this.fetchMetricLabels(metric);
+ refresher = this.fetchSeriesLabels(selector);
}
return {
@@ -208,59 +220,51 @@ class PromQueryField extends React.Component = null;
const suggestions: SuggestionGroup[] = [];
- if (metric) {
- const labelKeys = this.state.labelKeys[metric];
- if (labelKeys) {
- if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
- // Label values
- if (labelKey) {
- const labelValues = this.state.labelValues[metric][labelKey];
- context = 'context-label-values';
- suggestions.push({
- label: 'Label values',
- items: labelValues.map(wrapLabel),
- });
- }
- } else {
- // Label keys
- context = 'context-labels';
- suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
- }
- } else {
- refresher = this.fetchMetricLabels(metric);
+ const line = value.anchorBlock.getText();
+ const cursorOffset: number = value.anchorOffset;
+
+ // Get normalized selector
+ let selector;
+ try {
+ selector = getCleanSelector(line, cursorOffset);
+ } catch {
+ selector = EMPTY_SELECTOR;
+ }
+ const containsMetric = selector.indexOf('__name__=') > -1;
+
+ if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
+ // Label values
+ if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) {
+ const labelValues = this.state.labelValues[selector][labelKey];
+ context = 'context-label-values';
+ suggestions.push({
+ label: `Label values for "${labelKey}"`,
+ items: labelValues.map(wrapLabel),
+ });
}
} else {
- // Metric-independent label queries
- const defaultKeys = ['job', 'instance'];
- // Munge all keys that we have seen together
- const labelKeys = Object.keys(this.state.labelKeys).reduce((acc, metric) => {
- return acc.concat(this.state.labelKeys[metric].filter(key => acc.indexOf(key) === -1));
- }, defaultKeys);
- if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
- // Label values
- if (labelKey) {
- if (this.state.labelValues[EMPTY_METRIC]) {
- const labelValues = this.state.labelValues[EMPTY_METRIC][labelKey];
- context = 'context-label-values';
- suggestions.push({
- label: 'Label values',
- items: labelValues.map(wrapLabel),
- });
- } else {
- // Can only query label values for now (API to query keys is under development)
- refresher = this.fetchLabelValues(labelKey);
- }
- }
- } else {
- // Label keys
+ // Label keys
+ const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
+ if (labelKeys) {
context = 'context-labels';
- suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
+ suggestions.push({ label: `Labels`, items: labelKeys.map(wrapLabel) });
}
}
+
+ // Query labels for selector
+ if (selector && !this.state.labelValues[selector]) {
+ if (selector === EMPTY_SELECTOR) {
+ // Query label values for default labels
+ refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
+ } else {
+ refresher = this.fetchSeriesLabels(selector, !containsMetric);
+ }
+ }
+
return { context, refresher, suggestions };
}
@@ -276,14 +280,14 @@ class PromQueryField extends React.Component {
const selection = window.getSelection();
const { cleanText, onTypeahead } = this.props;
+ const { value } = this.state;
if (onTypeahead && selection.anchorNode) {
const wrapperNode = selection.anchorNode.parentElement;
@@ -221,6 +223,7 @@ class QueryField extends React.Component {
+ it('returns a clean selector from an empty selector', () => {
+ expect(getCleanSelector('{}', 1)).toBe('{}');
+ });
+ it('throws if selector is broken', () => {
+ expect(() => getCleanSelector('{foo')).toThrow();
+ });
+ it('returns the selector sorted by label key', () => {
+ expect(getCleanSelector('{foo="bar"}')).toBe('{foo="bar"}');
+ expect(getCleanSelector('{foo="bar",baz="xx"}')).toBe('{baz="xx",foo="bar"}');
+ });
+ it('returns a clean selector from an incomplete one', () => {
+ expect(getCleanSelector('{foo}')).toBe('{}');
+ expect(getCleanSelector('{foo="bar",baz}')).toBe('{foo="bar"}');
+ expect(getCleanSelector('{foo="bar",baz="}')).toBe('{foo="bar"}');
+ });
+ it('throws if not inside a selector', () => {
+ expect(() => getCleanSelector('foo{}', 0)).toThrow();
+ expect(() => getCleanSelector('foo{} + bar{}', 5)).toThrow();
+ });
+ it('returns the selector nearest to the cursor offset', () => {
+ expect(() => getCleanSelector('{foo="bar"} + {foo="bar"}', 0)).toThrow();
+ expect(getCleanSelector('{foo="bar"} + {foo="bar"}', 1)).toBe('{foo="bar"}');
+ expect(getCleanSelector('{foo="bar"} + {baz="xx"}', 1)).toBe('{foo="bar"}');
+ expect(getCleanSelector('{baz="xx"} + {foo="bar"}', 16)).toBe('{foo="bar"}');
+ });
+ it('returns a selector with metric if metric is given', () => {
+ expect(getCleanSelector('bar{foo}', 4)).toBe('{__name__="bar"}');
+ expect(getCleanSelector('baz{foo="bar"}', 12)).toBe('{__name__="baz",foo="bar"}');
+ });
+});
diff --git a/public/app/containers/Explore/utils/prometheus.ts b/public/app/containers/Explore/utils/prometheus.ts
index 30f9c25b8f7..ab77271076d 100644
--- a/public/app/containers/Explore/utils/prometheus.ts
+++ b/public/app/containers/Explore/utils/prometheus.ts
@@ -1,9 +1,16 @@
export const RATE_RANGES = ['1m', '5m', '10m', '30m', '1h'];
-export function processLabels(labels) {
+export function processLabels(labels, withName = false) {
const values = {};
labels.forEach(l => {
const { __name__, ...rest } = l;
+ if (withName) {
+ values['__name__'] = values['__name__'] || [];
+ if (values['__name__'].indexOf(__name__) === -1) {
+ values['__name__'].push(__name__);
+ }
+ }
+
Object.keys(rest).forEach(key => {
if (!values[key]) {
values[key] = [];
@@ -18,3 +25,64 @@ export function processLabels(labels) {
// Strip syntax chars
export const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
+
+// const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/;
+const selectorRegexp = /\{[^}]*?\}/;
+const labelRegexp = /\b\w+="[^"\n]*?"/g;
+export function getCleanSelector(query: string, cursorOffset = 1): string {
+ if (!query.match(selectorRegexp)) {
+ // Special matcher for metrics
+ if (query.match(/^\w+$/)) {
+ return `{__name__="${query}"}`;
+ }
+ throw new Error('Query must contain a selector: ' + query);
+ }
+
+ // Check if inside a selector
+ const prefix = query.slice(0, cursorOffset);
+ const prefixOpen = prefix.lastIndexOf('{');
+ const prefixClose = prefix.lastIndexOf('}');
+ if (prefixOpen === -1) {
+ throw new Error('Not inside selector, missing open brace: ' + prefix);
+ }
+ if (prefixClose > -1 && prefixClose > prefixOpen) {
+ throw new Error('Not inside selector, previous selector already closed: ' + prefix);
+ }
+ const suffix = query.slice(cursorOffset);
+ const suffixCloseIndex = suffix.indexOf('}');
+ const suffixClose = suffixCloseIndex + cursorOffset;
+ const suffixOpenIndex = suffix.indexOf('{');
+ const suffixOpen = suffixOpenIndex + cursorOffset;
+ if (suffixClose === -1) {
+ throw new Error('Not inside selector, missing closing brace in suffix: ' + suffix);
+ }
+ if (suffixOpenIndex > -1 && suffixOpen < suffixClose) {
+ throw new Error('Not inside selector, next selector opens before this one closed: ' + suffix);
+ }
+
+ // Extract clean labels to form clean selector, incomplete labels are dropped
+ const selector = query.slice(prefixOpen, suffixClose);
+ let labels = {};
+ selector.replace(labelRegexp, match => {
+ const delimiterIndex = match.indexOf('=');
+ const key = match.slice(0, delimiterIndex);
+ const value = match.slice(delimiterIndex + 1, match.length);
+ labels[key] = value;
+ return '';
+ });
+
+ // Add metric if there is one before the selector
+ const metricPrefix = query.slice(0, prefixOpen);
+ const metricMatch = metricPrefix.match(/\w+$/);
+ if (metricMatch) {
+ labels['__name__'] = `"${metricMatch[0]}"`;
+ }
+
+ // Build sorted selector
+ const cleanSelector = Object.keys(labels)
+ .sort()
+ .map(key => `${key}=${labels[key]}`)
+ .join(',');
+
+ return ['{', cleanSelector, '}'].join('');
+}