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(''); +}