Ver código fonte

Explore: facetting for label completion (#12786)

* Explore: facetting for label completion

- unified metric and non-metric label completion
- label keys and values are now fetched fresh for each valid selector
- complete selector means only values are suggested that are supported
  by the selector
- properly implemented metric lookup for selectors (until the first
  metric was used which breaks when multiple metrics are present)
- typeahead tests now need a valid selection to demark the cursor

* Fix facetting queries for empty selector
David 7 anos atrás
pai
commit
5da3584dd4

+ 73 - 19
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(<PromQueryField {...defaultProps} />).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(
-        <PromQueryField {...defaultProps} labelKeys={{ foo: ['bar'] }} />
+        <PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric"}': ['bar'] }} />
       ).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(
-        <PromQueryField {...defaultProps} labelKeys={{ foo: ['bar'] }} />
+        <PromQueryField {...defaultProps} labelKeys={{ '{__name__="foo"}': ['bar'] }} />
       ).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(
-        <PromQueryField {...defaultProps} labelKeys={{ foo: ['bar'] }} labelValues={{ foo: { bar: ['baz'] } }} />
+        <PromQueryField
+          {...defaultProps}
+          labelKeys={{ '{__name__="metric"}': ['bar'] }}
+          labelValues={{ '{__name__="metric"}': { bar: ['baz'] } }}
+        />
       ).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(
-        <PromQueryField {...defaultProps} labelKeys={{ foo: ['bar'] }} />
+        <PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric",foo="xx"}': ['bar'] }} />
       ).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: '',
+        prefix: '',
+        wrapperClasses: ['context-aggregation'],
+        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(
+        <PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric"}': ['bar'] }} />
+      ).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: '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' }]);

+ 70 - 66
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<PromQueryFieldProps, PromQueryFieldState> {
@@ -119,25 +121,23 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
   };
 
   onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
-    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<PromQueryFieldProps, PromQueryField
     } else if (_.includes(wrapperClasses, 'context-labels')) {
       // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
       return this.getLabelTypeahead.apply(this, arguments);
-    } else if (metric && _.includes(wrapperClasses, 'context-aggregation')) {
+    } else if (_.includes(wrapperClasses, 'context-aggregation')) {
       return this.getAggregationTypeahead.apply(this, arguments);
     } else if (
-      // Non-empty but not inside known token unless it's a metric
+      // Non-empty but not inside known token
       (prefix && !_.includes(wrapperClasses, 'token')) ||
-      prefix === metric ||
       (prefix === '' && !text.match(/^[)\s]+$/)) || // Empty context or after ')'
       text.match(/[+\-*/^%]/) // After binary operator
     ) {
@@ -191,14 +190,27 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
     };
   }
 
-  getAggregationTypeahead({ metric }: PromTypeaheadInput): TypeaheadOutput {
+  getAggregationTypeahead({ value }: PromTypeaheadInput): TypeaheadOutput {
     let refresher: Promise<any> = 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<PromQueryFieldProps, PromQueryField
     };
   }
 
-  getLabelTypeahead({ metric, text, wrapperClasses, labelKey }: PromTypeaheadInput): TypeaheadOutput {
+  getLabelTypeahead({ text, wrapperClasses, labelKey, value }: PromTypeaheadInput): TypeaheadOutput {
     let context: string;
     let refresher: Promise<any> = 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<PromQueryFieldProps, PromQueryField
     try {
       const res = await this.request(url);
       const body = await (res.data || res.json());
-      const pairs = this.state.labelValues[EMPTY_METRIC];
+      const exisingValues = this.state.labelValues[EMPTY_SELECTOR];
       const values = {
-        ...pairs,
+        ...exisingValues,
         [key]: body.data,
       };
       const labelValues = {
         ...this.state.labelValues,
-        [EMPTY_METRIC]: values,
+        [EMPTY_SELECTOR]: values,
       };
       this.setState({ labelValues });
     } catch (e) {
@@ -291,12 +295,12 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
     }
   }
 
-  async fetchMetricLabels(name) {
+  async fetchSeriesLabels(name, withName?) {
     const url = `/api/v1/series?match[]=${name}`;
     try {
       const res = await this.request(url);
       const body = await (res.data || res.json());
-      const { keys, values } = processLabels(body.data);
+      const { keys, values } = processLabels(body.data, withName);
       const labelKeys = {
         ...this.state.labelKeys,
         [name]: keys,

+ 3 - 0
public/app/containers/Explore/QueryField.tsx

@@ -126,6 +126,7 @@ export interface TypeaheadInput {
   prefix: string;
   selection?: Selection;
   text: string;
+  value: Value;
   wrapperNode: Element;
 }
 
@@ -199,6 +200,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
   handleTypeahead = _.debounce(async () => {
     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<TypeaheadFieldProps, TypeaheadFieldStat
         prefix,
         selection,
         text,
+        value,
         wrapperNode,
       });
 

+ 33 - 0
public/app/containers/Explore/utils/prometheus.jest.ts

@@ -0,0 +1,33 @@
+import { getCleanSelector } from './prometheus';
+
+describe('getCleanSelector()', () => {
+  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"}');
+  });
+});

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