瀏覽代碼

Merge pull request #13824 from grafana/davkal/explore-plugins

Explore: move suggestions logic to datasource language provider
David 7 年之前
父節點
當前提交
239dfbc9ae

+ 1 - 6
public/app/features/explore/Explore.tsx

@@ -695,11 +695,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     });
   }
 
-  request = url => {
-    const { datasource } = this.state;
-    return datasource.metadataRequest(url);
-  };
-
   cloneState(): ExploreState {
     // Copy state, but copy queries including modifications
     return {
@@ -831,9 +826,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
         {datasource && !datasourceError ? (
           <div className="explore-container">
             <QueryRows
+              datasource={datasource}
               history={history}
               queries={queries}
-              request={this.request}
               onAddQueryRow={this.onAddQueryRow}
               onChangeQuery={this.onChangeQuery}
               onClickHintFix={this.onModifyQueries}

+ 1 - 228
public/app/features/explore/PromQueryField.test.tsx

@@ -1,231 +1,4 @@
-import React from 'react';
-import Enzyme, { shallow } from 'enzyme';
-import Adapter from 'enzyme-adapter-react-16';
-import Plain from 'slate-plain-serializer';
-
-import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
-
-Enzyme.configure({ adapter: new Adapter() });
-
-describe('PromQueryField typeahead handling', () => {
-  const defaultProps = {
-    request: () => ({ data: { data: [] } }),
-  };
-
-  it('returns default suggestions on emtpty context', () => {
-    const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
-    const result = instance.getTypeahead({ text: '', prefix: '', wrapperClasses: [] });
-    expect(result.context).toBeUndefined();
-    expect(result.refresher).toBeUndefined();
-    expect(result.suggestions.length).toEqual(2);
-  });
-
-  describe('range suggestions', () => {
-    it('returns range suggestions in range context', () => {
-      const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
-      const result = instance.getTypeahead({ text: '1', prefix: '1', wrapperClasses: ['context-range'] });
-      expect(result.context).toBe('context-range');
-      expect(result.refresher).toBeUndefined();
-      expect(result.suggestions).toEqual([
-        {
-          items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }],
-          label: 'Range vector',
-        },
-      ]);
-    });
-  });
-
-  describe('metric suggestions', () => {
-    it('returns metrics suggestions by default', () => {
-      const instance = shallow(
-        <PromQueryField {...defaultProps} metrics={['foo', 'bar']} />
-      ).instance() as PromQueryField;
-      const result = instance.getTypeahead({ text: 'a', prefix: 'a', wrapperClasses: [] });
-      expect(result.context).toBeUndefined();
-      expect(result.refresher).toBeUndefined();
-      expect(result.suggestions.length).toEqual(2);
-    });
-
-    it('returns default suggestions after a binary operator', () => {
-      const instance = shallow(
-        <PromQueryField {...defaultProps} metrics={['foo', 'bar']} />
-      ).instance() as PromQueryField;
-      const result = instance.getTypeahead({ text: '*', prefix: '', wrapperClasses: [] });
-      expect(result.context).toBeUndefined();
-      expect(result.refresher).toBeUndefined();
-      expect(result.suggestions.length).toEqual(2);
-    });
-  });
-
-  describe('label suggestions', () => {
-    it('returns default label suggestions on label context and no metric', () => {
-      const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
-      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={{ '{__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: '',
-        prefix: '',
-        wrapperClasses: ['context-labels'],
-        value: valueWithSelection,
-      });
-      expect(result.context).toBe('context-labels');
-      expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
-    });
-
-    it('returns label suggestions on label context but leaves out labels that already exist', () => {
-      const instance = shallow(
-        <PromQueryField
-          {...defaultProps}
-          labelKeys={{ '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] }}
-        />
-      ).instance() as PromQueryField;
-      const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}');
-      const range = value.selection.merge({
-        anchorOffset: 36,
-      });
-      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: 'bar' }], label: 'Labels' }]);
-    });
-
-    it('returns label value suggestions inside a label value context after a negated matching operator', () => {
-      const instance = shallow(
-        <PromQueryField
-          {...defaultProps}
-          labelKeys={{ '{}': ['label'] }}
-          labelValues={{ '{}': { label: ['a', 'b', 'c'] } }}
-        />
-      ).instance() as PromQueryField;
-      const value = Plain.deserialize('{label!=}');
-      const range = value.selection.merge({ anchorOffset: 8 });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.getTypeahead({
-        text: '!=',
-        prefix: '',
-        wrapperClasses: ['context-labels'],
-        labelKey: 'label',
-        value: valueWithSelection,
-      });
-      expect(result.context).toBe('context-label-values');
-      expect(result.suggestions).toEqual([
-        {
-          items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }],
-          label: 'Label values for "label"',
-        },
-      ]);
-    });
-
-    it('returns a refresher on label context and unavailable metric', () => {
-      const instance = shallow(
-        <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: '',
-        prefix: '',
-        wrapperClasses: ['context-labels'],
-        value: valueWithSelection,
-      });
-      expect(result.context).toBeUndefined();
-      expect(result.refresher).toBeInstanceOf(Promise);
-      expect(result.suggestions).toEqual([]);
-    });
-
-    it('returns label values on label context when given a metric and a label key', () => {
-      const instance = shallow(
-        <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'],
-        labelKey: 'bar',
-        value: valueWithSelection,
-      });
-      expect(result.context).toBe('context-label-values');
-      expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]);
-    });
-
-    it('returns label suggestions on aggregation context and metric w/ selector', () => {
-      const instance = shallow(
-        <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: '',
-        prefix: '',
-        wrapperClasses: ['context-aggregation'],
-        value: valueWithSelection,
-      });
-      expect(result.context).toBe('context-aggregation');
-      expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
-    });
-  });
-});
+import { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
 
 describe('groupMetricsByPrefix()', () => {
   it('returns an empty group for no metrics', () => {

+ 31 - 347
public/app/features/explore/PromQueryField.tsx

@@ -1,67 +1,23 @@
 import _ from 'lodash';
-import moment from 'moment';
 import React from 'react';
-import { Value } from 'slate';
 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 PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql';
 import BracesPlugin from './slate-plugins/braces';
 import RunnerPlugin from './slate-plugins/runner';
-import { processLabels, RATE_RANGES, cleanText, parseSelector } from './utils/prometheus';
-
-import TypeaheadField, {
-  Suggestion,
-  SuggestionGroup,
-  TypeaheadInput,
-  TypeaheadFieldState,
-  TypeaheadOutput,
-} from './QueryField';
-
-const DEFAULT_KEYS = ['job', 'instance'];
-const EMPTY_SELECTOR = '{}';
+
+import TypeaheadField, { TypeaheadInput, TypeaheadFieldState } from './QueryField';
+
 const HISTOGRAM_GROUP = '__histograms__';
-const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms
-const HISTORY_ITEM_COUNT = 5;
-const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
 const METRIC_MARK = 'metric';
 const PRISM_SYNTAX = 'promql';
 export const RECORDING_RULES_GROUP = '__recording_rules__';
 
-export const wrapLabel = (label: string) => ({ label });
-export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
-  suggestion.move = -1;
-  return suggestion;
-};
-
-// Syntax highlighting
-Prism.languages[PRISM_SYNTAX] = PrismPromql;
-function setPrismTokens(language, field, values, alias = 'variable') {
-  Prism.languages[language][field] = {
-    alias,
-    pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`),
-  };
-}
-
-export function addHistoryMetadata(item: Suggestion, history: any[]): Suggestion {
-  const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
-  const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label);
-  const count = historyForItem.length;
-  const recent = historyForItem[0];
-  let hint = `Queried ${count} times in the last 24h.`;
-  if (recent) {
-    const lastQueried = moment(recent.ts).fromNow();
-    hint = `${hint} Last queried ${lastQueried}.`;
-  }
-  return {
-    ...item,
-    documentation: hint,
-  };
-}
-
 export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
   // Filter out recording rules and insert as first option
   const ruleRegex = /:\w+:/;
@@ -133,48 +89,36 @@ interface CascaderOption {
 }
 
 interface PromQueryFieldProps {
+  datasource: any;
   error?: string;
   hint?: any;
-  histogramMetrics?: string[];
   history?: any[];
   initialQuery?: string | null;
-  labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
-  labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
-  metrics?: string[];
   metricsByPrefix?: CascaderOption[];
   onClickHintFix?: (action: any) => void;
   onPressEnter?: () => void;
   onQueryChange?: (value: string, override?: boolean) => void;
-  portalOrigin?: string;
-  request?: (url: string) => any;
   supportsLogs?: boolean; // To be removed after Logging gets its own query field
 }
 
 interface PromQueryFieldState {
-  histogramMetrics: string[];
-  labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...]
-  labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
   logLabelOptions: any[];
-  metrics: string[];
   metricsOptions: any[];
   metricsByPrefix: CascaderOption[];
   syntaxLoaded: boolean;
 }
 
-interface PromTypeaheadInput {
-  text: string;
-  prefix: string;
-  wrapperClasses: string[];
-  labelKey?: string;
-  value?: Value;
-}
-
 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 }),
@@ -185,26 +129,16 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
     ];
 
     this.state = {
-      histogramMetrics: props.histogramMetrics || [],
-      labelKeys: props.labelKeys || {},
-      labelValues: props.labelValues || {},
       logLabelOptions: [],
-      metrics: props.metrics || [],
-      metricsByPrefix: props.metricsByPrefix || [],
+      metricsByPrefix: [],
       metricsOptions: [],
       syntaxLoaded: false,
     };
   }
 
   componentDidMount() {
-    // Temporarily reused by logging
-    const { supportsLogs } = this.props;
-    if (supportsLogs) {
-      this.fetchLogLabels();
-    } else {
-      // Usual actions
-      this.fetchMetricNames();
-      this.fetchHistogramMetrics();
+    if (this.languageProvider) {
+      this.languageProvider.start().then(() => this.onReceiveMetrics());
     }
   }
 
@@ -262,15 +196,19 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
   };
 
   onReceiveMetrics = () => {
-    const { histogramMetrics, metrics, metricsByPrefix } = this.state;
+    const { histogramMetrics, metrics } = this.languageProvider;
     if (!metrics) {
       return;
     }
 
-    // Update global prism config
-    setPrismTokens(PRISM_SYNTAX, METRIC_MARK, metrics);
+    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 },
@@ -281,6 +219,11 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
   };
 
   onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
+    if (!this.languageProvider) {
+      return { suggestions: [] };
+    }
+
+    const { history } = this.props;
     const { prefix, text, value, wrapperNode } = typeahead;
 
     // Get DOM-dependent context
@@ -289,279 +232,20 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
     const labelKey = labelKeyNode && labelKeyNode.textContent;
     const nextChar = getNextCharacter();
 
-    const result = this.getTypeahead({ text, value, prefix, wrapperClasses, labelKey });
+    const result = this.languageProvider.provideCompletionItems(
+      { text, value, prefix, wrapperClasses, labelKey },
+      { history }
+    );
 
     console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
 
     return result;
   };
 
-  // Keep this DOM-free for testing
-  getTypeahead({ prefix, wrapperClasses, text }: PromTypeaheadInput): TypeaheadOutput {
-    // Syntax spans have 3 classes by default. More indicate a recognized token
-    const tokenRecognized = wrapperClasses.length > 3;
-    // Determine candidates by CSS context
-    if (_.includes(wrapperClasses, 'context-range')) {
-      // Suggestions for metric[|]
-      return this.getRangeTypeahead();
-    } 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 (_.includes(wrapperClasses, 'context-aggregation')) {
-      return this.getAggregationTypeahead.apply(this, arguments);
-    } else if (
-      // Show default suggestions in a couple of scenarios
-      (prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
-      (prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
-      text.match(/[+\-*/^%]/) // Anything after binary operator
-    ) {
-      return this.getEmptyTypeahead();
-    }
-
-    return {
-      suggestions: [],
-    };
-  }
-
-  getEmptyTypeahead(): TypeaheadOutput {
-    const { history } = this.props;
-    const { metrics } = this.state;
-    const suggestions: SuggestionGroup[] = [];
-
-    if (history && history.length > 0) {
-      const historyItems = _.chain(history)
-        .uniqBy('query')
-        .take(HISTORY_ITEM_COUNT)
-        .map(h => h.query)
-        .map(wrapLabel)
-        .map(item => addHistoryMetadata(item, history))
-        .value();
-
-      suggestions.push({
-        prefixMatch: true,
-        skipSort: true,
-        label: 'History',
-        items: historyItems,
-      });
-    }
-
-    suggestions.push({
-      prefixMatch: true,
-      label: 'Functions',
-      items: FUNCTIONS.map(setFunctionMove),
-    });
-
-    if (metrics) {
-      suggestions.push({
-        label: 'Metrics',
-        items: metrics.map(wrapLabel),
-      });
-    }
-    return { suggestions };
-  }
-
-  getRangeTypeahead(): TypeaheadOutput {
-    return {
-      context: 'context-range',
-      suggestions: [
-        {
-          label: 'Range vector',
-          items: [...RATE_RANGES].map(wrapLabel),
-        },
-      ],
-    };
-  }
-
-  getAggregationTypeahead({ value }: PromTypeaheadInput): TypeaheadOutput {
-    let refresher: Promise<any> = null;
-    const suggestions: SuggestionGroup[] = [];
-
-    // 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 = parseSelector(selectorString, selectorString.length - 2).selector;
-
-    const labelKeys = this.state.labelKeys[selector];
-    if (labelKeys) {
-      suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
-    } else {
-      refresher = this.fetchSeriesLabels(selector);
-    }
-
-    return {
-      refresher,
-      suggestions,
-      context: 'context-aggregation',
-    };
-  }
-
-  getLabelTypeahead({ text, wrapperClasses, labelKey, value }: PromTypeaheadInput): TypeaheadOutput {
-    let context: string;
-    let refresher: Promise<any> = null;
-    const suggestions: SuggestionGroup[] = [];
-    const line = value.anchorBlock.getText();
-    const cursorOffset: number = value.anchorOffset;
-
-    // Get normalized selector
-    let selector;
-    let parsedSelector;
-    try {
-      parsedSelector = parseSelector(line, cursorOffset);
-      selector = parsedSelector.selector;
-    } catch {
-      selector = EMPTY_SELECTOR;
-    }
-    const containsMetric = selector.indexOf('__name__=') > -1;
-    const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
-
-    if ((text && text.match(/^!?=~?/)) || _.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 {
-      // Label keys
-      const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
-      if (labelKeys) {
-        const possibleKeys = _.difference(labelKeys, existingKeys);
-        if (possibleKeys.length > 0) {
-          context = 'context-labels';
-          suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
-        }
-      }
-    }
-
-    // Query labels for selector
-    // Temporarily add skip for logging
-    if (selector && !this.state.labelValues[selector] && !this.props.supportsLogs) {
-      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 };
-  }
-
-  request = url => {
-    if (this.props.request) {
-      return this.props.request(url);
-    }
-    return fetch(url);
-  };
-
-  fetchHistogramMetrics() {
-    this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true, () => {
-      const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR];
-      if (histogramSeries && histogramSeries['__name__']) {
-        const histogramMetrics = histogramSeries['__name__'].slice().sort();
-        this.setState({ histogramMetrics }, this.onReceiveMetrics);
-      }
-    });
-  }
-
-  // Temporarily here while reusing this field for logging
-  async fetchLogLabels() {
-    const url = '/api/prom/label';
-    try {
-      const res = await this.request(url);
-      const body = await (res.data || res.json());
-      const labelKeys = body.data.slice().sort();
-      const labelKeysBySelector = {
-        ...this.state.labelKeys,
-        [EMPTY_SELECTOR]: labelKeys,
-      };
-      const labelValuesByKey = {};
-      const logLabelOptions = [];
-      for (const key of labelKeys) {
-        const valuesUrl = `/api/prom/label/${key}/values`;
-        const res = await this.request(valuesUrl);
-        const body = await (res.data || res.json());
-        const values = body.data.slice().sort();
-        labelValuesByKey[key] = values;
-        logLabelOptions.push({
-          label: key,
-          value: key,
-          children: values.map(value => ({ label: value, value })),
-        });
-      }
-      const labelValues = { [EMPTY_SELECTOR]: labelValuesByKey };
-      this.setState({ labelKeys: labelKeysBySelector, labelValues, logLabelOptions });
-    } catch (e) {
-      console.error(e);
-    }
-  }
-
-  async fetchLabelValues(key: string) {
-    const url = `/api/v1/label/${key}/values`;
-    try {
-      const res = await this.request(url);
-      const body = await (res.data || res.json());
-      const exisingValues = this.state.labelValues[EMPTY_SELECTOR];
-      const values = {
-        ...exisingValues,
-        [key]: body.data,
-      };
-      const labelValues = {
-        ...this.state.labelValues,
-        [EMPTY_SELECTOR]: values,
-      };
-      this.setState({ labelValues });
-    } catch (e) {
-      console.error(e);
-    }
-  }
-
-  async fetchSeriesLabels(name: string, withName?: boolean, callback?: () => void) {
-    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, withName);
-      const labelKeys = {
-        ...this.state.labelKeys,
-        [name]: keys,
-      };
-      const labelValues = {
-        ...this.state.labelValues,
-        [name]: values,
-      };
-      this.setState({ labelKeys, labelValues }, callback);
-    } catch (e) {
-      console.error(e);
-    }
-  }
-
-  async fetchMetricNames() {
-    const url = '/api/v1/label/__name__/values';
-    try {
-      const res = await this.request(url);
-      const body = await (res.data || res.json());
-      const metrics = body.data;
-      const metricsByPrefix = groupMetricsByPrefix(metrics);
-      this.setState({ metrics, metricsByPrefix }, this.onReceiveMetrics);
-    } catch (error) {
-      console.error(error);
-    }
-  }
-
   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">

+ 7 - 81
public/app/features/explore/QueryField.tsx

@@ -5,6 +5,8 @@ import { Change, Value } from 'slate';
 import { Editor } from 'slate-react';
 import Plain from 'slate-plain-serializer';
 
+import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
+
 import ClearPlugin from './slate-plugins/clear';
 import NewlinePlugin from './slate-plugins/newline';
 
@@ -13,87 +15,17 @@ import { makeFragment, makeValue } from './Value';
 
 export const TYPEAHEAD_DEBOUNCE = 100;
 
-function getSuggestionByIndex(suggestions: SuggestionGroup[], index: number): Suggestion {
+function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem {
   // Flatten suggestion groups
   const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []);
   const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length;
   return flattenedSuggestions[correctedIndex];
 }
 
-function hasSuggestions(suggestions: SuggestionGroup[]): boolean {
+function hasSuggestions(suggestions: CompletionItemGroup[]): boolean {
   return suggestions && suggestions.length > 0;
 }
 
-export interface Suggestion {
-  /**
-   * The label of this completion item. By default
-   * this is also the text that is inserted when selecting
-   * this completion.
-   */
-  label: string;
-  /**
-   * The kind of this completion item. Based on the kind
-   * an icon is chosen by the editor.
-   */
-  kind?: string;
-  /**
-   * A human-readable string with additional information
-   * about this item, like type or symbol information.
-   */
-  detail?: string;
-  /**
-   * A human-readable string, can be Markdown, that represents a doc-comment.
-   */
-  documentation?: string;
-  /**
-   * A string that should be used when comparing this item
-   * with other items. When `falsy` the `label` is used.
-   */
-  sortText?: string;
-  /**
-   * A string that should be used when filtering a set of
-   * completion items. When `falsy` the `label` is used.
-   */
-  filterText?: string;
-  /**
-   * A string or snippet that should be inserted in a document when selecting
-   * this completion. When `falsy` the `label` is used.
-   */
-  insertText?: string;
-  /**
-   * Delete number of characters before the caret position,
-   * by default the letters from the beginning of the word.
-   */
-  deleteBackwards?: number;
-  /**
-   * Number of steps to move after the insertion, can be negative.
-   */
-  move?: number;
-}
-
-export interface SuggestionGroup {
-  /**
-   * Label that will be displayed for all entries of this group.
-   */
-  label: string;
-  /**
-   * List of suggestions of this group.
-   */
-  items: Suggestion[];
-  /**
-   * If true, match only by prefix (and not mid-word).
-   */
-  prefixMatch?: boolean;
-  /**
-   * If true, do not filter items in this group based on the search.
-   */
-  skipFilter?: boolean;
-  /**
-   * If true, do not sort items.
-   */
-  skipSort?: boolean;
-}
-
 interface TypeaheadFieldProps {
   additionalPlugins?: any[];
   cleanText?: (text: string) => string;
@@ -110,7 +42,7 @@ interface TypeaheadFieldProps {
 }
 
 export interface TypeaheadFieldState {
-  suggestions: SuggestionGroup[];
+  suggestions: CompletionItemGroup[];
   typeaheadContext: string | null;
   typeaheadIndex: number;
   typeaheadPrefix: string;
@@ -127,12 +59,6 @@ export interface TypeaheadInput {
   wrapperNode: Element;
 }
 
-export interface TypeaheadOutput {
-  context?: string;
-  refresher?: Promise<{}>;
-  suggestions: SuggestionGroup[];
-}
-
 class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadFieldState> {
   menuEl: HTMLElement | null;
   plugins: any[];
@@ -293,7 +219,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
     }
   }, TYPEAHEAD_DEBOUNCE);
 
-  applyTypeahead(change: Change, suggestion: Suggestion): Change {
+  applyTypeahead(change: Change, suggestion: CompletionItem): Change {
     const { cleanText, onWillApplySuggestion, syntax } = this.props;
     const { typeaheadPrefix, typeaheadText } = this.state;
     let suggestionText = suggestion.insertText || suggestion.label;
@@ -422,7 +348,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
     }
   };
 
-  onClickMenu = (item: Suggestion) => {
+  onClickMenu = (item: CompletionItem) => {
     // Manually triggering change
     const change = this.applyTypeahead(this.state.value.change(), item);
     this.onChange(change);

+ 36 - 8
public/app/features/explore/QueryRows.tsx

@@ -1,12 +1,12 @@
 import React, { PureComponent } from 'react';
 
-import { QueryTransaction } from 'app/types/explore';
+import { QueryTransaction, HistoryItem, Query, QueryHint } from 'app/types/explore';
 
 // TODO make this datasource-plugin-dependent
 import QueryField from './PromQueryField';
 import QueryTransactions from './QueryTransactions';
 
-function getFirstHintFromTransactions(transactions: QueryTransaction[]) {
+function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
   const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
   if (transaction) {
     return transaction.hints[0];
@@ -14,7 +14,30 @@ function getFirstHintFromTransactions(transactions: QueryTransaction[]) {
   return undefined;
 }
 
-class QueryRow extends PureComponent<any, {}> {
+interface QueryRowEventHandlers {
+  onAddQueryRow: (index: number) => void;
+  onChangeQuery: (value: string, index: number, override?: boolean) => void;
+  onClickHintFix: (action: object, index?: number) => void;
+  onExecuteQuery: () => void;
+  onRemoveQueryRow: (index: number) => void;
+}
+
+interface QueryRowCommonProps {
+  className?: string;
+  datasource: any;
+  history: HistoryItem[];
+  // Temporarily
+  supportsLogs?: boolean;
+  transactions: QueryTransaction[];
+}
+
+type QueryRowProps = QueryRowCommonProps &
+  QueryRowEventHandlers & {
+    index: number;
+    query: string;
+  };
+
+class QueryRow extends PureComponent<QueryRowProps> {
   onChangeQuery = (value, override?: boolean) => {
     const { index, onChangeQuery } = this.props;
     if (onChangeQuery) {
@@ -55,8 +78,8 @@ class QueryRow extends PureComponent<any, {}> {
   };
 
   render() {
-    const { history, query, request, supportsLogs, transactions } = this.props;
-    const transactionWithError = transactions.find(t => t.error);
+    const { 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;
     return (
@@ -66,6 +89,7 @@ class QueryRow extends PureComponent<any, {}> {
         </div>
         <div className="query-row-field">
           <QueryField
+            datasource={datasource}
             error={queryError}
             hint={hint}
             initialQuery={query}
@@ -73,7 +97,6 @@ class QueryRow extends PureComponent<any, {}> {
             onClickHintFix={this.onClickHintFix}
             onPressEnter={this.onPressEnter}
             onQueryChange={this.onChangeQuery}
-            request={request}
             supportsLogs={supportsLogs}
           />
         </div>
@@ -93,9 +116,14 @@ class QueryRow extends PureComponent<any, {}> {
   }
 }
 
-export default class QueryRows extends PureComponent<any, {}> {
+type QueryRowsProps = QueryRowCommonProps &
+  QueryRowEventHandlers & {
+    queries: Query[];
+  };
+
+export default class QueryRows extends PureComponent<QueryRowsProps> {
   render() {
-    const { className = '', queries, queryHints, transactions, ...handlers } = this.props;
+    const { className = '', queries, transactions, ...handlers } = this.props;
     return (
       <div className={className}>
         {queries.map((q, index) => (

+ 10 - 10
public/app/features/explore/Typeahead.tsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import Highlighter from 'react-highlight-words';
 
-import { Suggestion, SuggestionGroup } from './QueryField';
+import { CompletionItem, CompletionItemGroup } from 'app/types/explore';
 
 function scrollIntoView(el: HTMLElement) {
   if (!el || !el.offsetParent) {
@@ -15,12 +15,12 @@ function scrollIntoView(el: HTMLElement) {
 
 interface TypeaheadItemProps {
   isSelected: boolean;
-  item: Suggestion;
+  item: CompletionItem;
   onClickItem: (Suggestion) => void;
   prefix?: string;
 }
 
-class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
+class TypeaheadItem extends React.PureComponent<TypeaheadItemProps> {
   el: HTMLElement;
 
   componentDidUpdate(prevProps) {
@@ -53,14 +53,14 @@ class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
 }
 
 interface TypeaheadGroupProps {
-  items: Suggestion[];
+  items: CompletionItem[];
   label: string;
-  onClickItem: (Suggestion) => void;
-  selected: Suggestion;
+  onClickItem: (CompletionItem) => void;
+  selected: CompletionItem;
   prefix?: string;
 }
 
-class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
+class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps> {
   render() {
     const { items, label, selected, onClickItem, prefix } = this.props;
     return (
@@ -85,13 +85,13 @@ class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
 }
 
 interface TypeaheadProps {
-  groupedItems: SuggestionGroup[];
+  groupedItems: CompletionItemGroup[];
   menuRef: any;
-  selectedItem: Suggestion | null;
+  selectedItem: CompletionItem | null;
   onClickItem: (Suggestion) => void;
   prefix?: string;
 }
-class Typeahead extends React.PureComponent<TypeaheadProps, {}> {
+class Typeahead extends React.PureComponent<TypeaheadProps> {
   render() {
     const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props;
     return (

+ 3 - 0
public/app/plugins/datasource/prometheus/datasource.ts

@@ -5,6 +5,7 @@ import kbn from 'app/core/utils/kbn';
 import * as dateMath from 'app/core/utils/datemath';
 import PrometheusMetricFindQuery from './metric_find_query';
 import { ResultTransformer } from './result_transformer';
+import PrometheusLanguageProvider from './language_provider';
 import { BackendSrv } from 'app/core/services/backend_srv';
 
 import addLabelToQuery from './add_label_to_query';
@@ -60,6 +61,7 @@ export class PrometheusDatasource {
   interval: string;
   queryTimeout: string;
   httpMethod: string;
+  languageProvider: PrometheusLanguageProvider;
   resultTransformer: ResultTransformer;
 
   /** @ngInject */
@@ -76,6 +78,7 @@ export class PrometheusDatasource {
     this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
     this.resultTransformer = new ResultTransformer(templateSrv);
     this.ruleMappings = {};
+    this.languageProvider = new PrometheusLanguageProvider(this);
   }
 
   init() {

+ 334 - 0
public/app/plugins/datasource/prometheus/language_provider.ts

@@ -0,0 +1,334 @@
+import _ from 'lodash';
+import moment from 'moment';
+
+import {
+  CompletionItem,
+  CompletionItemGroup,
+  LanguageProvider,
+  TypeaheadInput,
+  TypeaheadOutput,
+} from 'app/types/explore';
+
+import { parseSelector, processLabels, RATE_RANGES } from './language_utils';
+import PromqlSyntax, { FUNCTIONS } from './promql';
+
+const DEFAULT_KEYS = ['job', 'instance'];
+const EMPTY_SELECTOR = '{}';
+const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms
+const HISTORY_ITEM_COUNT = 5;
+const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
+
+const wrapLabel = (label: string) => ({ label });
+
+const setFunctionMove = (suggestion: CompletionItem): CompletionItem => {
+  suggestion.move = -1;
+  return suggestion;
+};
+
+export function addHistoryMetadata(item: CompletionItem, history: any[]): CompletionItem {
+  const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
+  const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label);
+  const count = historyForItem.length;
+  const recent = historyForItem[0];
+  let hint = `Queried ${count} times in the last 24h.`;
+  if (recent) {
+    const lastQueried = moment(recent.ts).fromNow();
+    hint = `${hint} Last queried ${lastQueried}.`;
+  }
+  return {
+    ...item,
+    documentation: hint,
+  };
+}
+
+export default class PromQlLanguageProvider extends LanguageProvider {
+  histogramMetrics?: string[];
+  labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
+  labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
+  metrics?: string[];
+  logLabelOptions: any[];
+  supportsLogs?: boolean;
+  started: boolean;
+
+  constructor(datasource: any, initialValues?: any) {
+    super();
+
+    this.datasource = datasource;
+    this.histogramMetrics = [];
+    this.labelKeys = {};
+    this.labelValues = {};
+    this.metrics = [];
+    this.supportsLogs = false;
+    this.started = false;
+
+    Object.assign(this, initialValues);
+  }
+  // Strip syntax chars
+  cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
+
+  getSyntax() {
+    return PromqlSyntax;
+  }
+
+  request = url => {
+    return this.datasource.metadataRequest(url);
+  };
+
+  start = () => {
+    if (!this.started) {
+      this.started = true;
+      return Promise.all([this.fetchMetricNames(), this.fetchHistogramMetrics()]);
+    }
+    return Promise.resolve([]);
+  };
+
+  // Keep this DOM-free for testing
+  provideCompletionItems({ prefix, wrapperClasses, text }: TypeaheadInput, context?: any): TypeaheadOutput {
+    // Syntax spans have 3 classes by default. More indicate a recognized token
+    const tokenRecognized = wrapperClasses.length > 3;
+    // Determine candidates by CSS context
+    if (_.includes(wrapperClasses, 'context-range')) {
+      // Suggestions for metric[|]
+      return this.getRangeCompletionItems();
+    } else if (_.includes(wrapperClasses, 'context-labels')) {
+      // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
+      return this.getLabelCompletionItems.apply(this, arguments);
+    } else if (_.includes(wrapperClasses, 'context-aggregation')) {
+      return this.getAggregationCompletionItems.apply(this, arguments);
+    } else if (
+      // Show default suggestions in a couple of scenarios
+      (prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
+      (prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
+      text.match(/[+\-*/^%]/) // Anything after binary operator
+    ) {
+      return this.getEmptyCompletionItems(context || {});
+    }
+
+    return {
+      suggestions: [],
+    };
+  }
+
+  getEmptyCompletionItems(context: any): TypeaheadOutput {
+    const { history } = context;
+    const { metrics } = this;
+    const suggestions: CompletionItemGroup[] = [];
+
+    if (history && history.length > 0) {
+      const historyItems = _.chain(history)
+        .uniqBy('query')
+        .take(HISTORY_ITEM_COUNT)
+        .map(h => h.query)
+        .map(wrapLabel)
+        .map(item => addHistoryMetadata(item, history))
+        .value();
+
+      suggestions.push({
+        prefixMatch: true,
+        skipSort: true,
+        label: 'History',
+        items: historyItems,
+      });
+    }
+
+    suggestions.push({
+      prefixMatch: true,
+      label: 'Functions',
+      items: FUNCTIONS.map(setFunctionMove),
+    });
+
+    if (metrics) {
+      suggestions.push({
+        label: 'Metrics',
+        items: metrics.map(wrapLabel),
+      });
+    }
+    return { suggestions };
+  }
+
+  getRangeCompletionItems(): TypeaheadOutput {
+    return {
+      context: 'context-range',
+      suggestions: [
+        {
+          label: 'Range vector',
+          items: [...RATE_RANGES].map(wrapLabel),
+        },
+      ],
+    };
+  }
+
+  getAggregationCompletionItems({ value }: TypeaheadInput): TypeaheadOutput {
+    let refresher: Promise<any> = null;
+    const suggestions: CompletionItemGroup[] = [];
+
+    // 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 = parseSelector(selectorString, selectorString.length - 2).selector;
+
+    const labelKeys = this.labelKeys[selector];
+    if (labelKeys) {
+      suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
+    } else {
+      refresher = this.fetchSeriesLabels(selector);
+    }
+
+    return {
+      refresher,
+      suggestions,
+      context: 'context-aggregation',
+    };
+  }
+
+  getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): TypeaheadOutput {
+    let context: string;
+    let refresher: Promise<any> = null;
+    const suggestions: CompletionItemGroup[] = [];
+    const line = value.anchorBlock.getText();
+    const cursorOffset: number = value.anchorOffset;
+
+    // Get normalized selector
+    let selector;
+    let parsedSelector;
+    try {
+      parsedSelector = parseSelector(line, cursorOffset);
+      selector = parsedSelector.selector;
+    } catch {
+      selector = EMPTY_SELECTOR;
+    }
+    const containsMetric = selector.indexOf('__name__=') > -1;
+    const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
+
+    if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
+      // Label values
+      if (labelKey && this.labelValues[selector] && this.labelValues[selector][labelKey]) {
+        const labelValues = this.labelValues[selector][labelKey];
+        context = 'context-label-values';
+        suggestions.push({
+          label: `Label values for "${labelKey}"`,
+          items: labelValues.map(wrapLabel),
+        });
+      }
+    } else {
+      // Label keys
+      const labelKeys = this.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
+      if (labelKeys) {
+        const possibleKeys = _.difference(labelKeys, existingKeys);
+        if (possibleKeys.length > 0) {
+          context = 'context-labels';
+          suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
+        }
+      }
+    }
+
+    // Query labels for selector
+    // Temporarily add skip for logging
+    if (selector && !this.labelValues[selector] && !this.supportsLogs) {
+      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 };
+  }
+
+  async fetchMetricNames() {
+    const url = '/api/v1/label/__name__/values';
+    try {
+      const res = await this.request(url);
+      const body = await (res.data || res.json());
+      this.metrics = body.data;
+    } catch (error) {
+      console.error(error);
+    }
+  }
+
+  async fetchHistogramMetrics() {
+    await this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true);
+    const histogramSeries = this.labelValues[HISTOGRAM_SELECTOR];
+    if (histogramSeries && histogramSeries['__name__']) {
+      this.histogramMetrics = histogramSeries['__name__'].slice().sort();
+    }
+  }
+
+  // Temporarily here while reusing this field for logging
+  async fetchLogLabels() {
+    const url = '/api/prom/label';
+    try {
+      const res = await this.request(url);
+      const body = await (res.data || res.json());
+      const labelKeys = body.data.slice().sort();
+      const labelKeysBySelector = {
+        ...this.labelKeys,
+        [EMPTY_SELECTOR]: labelKeys,
+      };
+      const labelValuesByKey = {};
+      this.logLabelOptions = [];
+      for (const key of labelKeys) {
+        const valuesUrl = `/api/prom/label/${key}/values`;
+        const res = await this.request(valuesUrl);
+        const body = await (res.data || res.json());
+        const values = body.data.slice().sort();
+        labelValuesByKey[key] = values;
+        this.logLabelOptions.push({
+          label: key,
+          value: key,
+          children: values.map(value => ({ label: value, value })),
+        });
+      }
+      this.labelValues = { [EMPTY_SELECTOR]: labelValuesByKey };
+      this.labelKeys = labelKeysBySelector;
+    } catch (e) {
+      console.error(e);
+    }
+  }
+
+  async fetchLabelValues(key: string) {
+    const url = `/api/v1/label/${key}/values`;
+    try {
+      const res = await this.request(url);
+      const body = await (res.data || res.json());
+      const exisingValues = this.labelValues[EMPTY_SELECTOR];
+      const values = {
+        ...exisingValues,
+        [key]: body.data,
+      };
+      this.labelValues = {
+        ...this.labelValues,
+        [EMPTY_SELECTOR]: values,
+      };
+    } catch (e) {
+      console.error(e);
+    }
+  }
+
+  async fetchSeriesLabels(name: string, withName?: boolean) {
+    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, withName);
+      this.labelKeys = {
+        ...this.labelKeys,
+        [name]: keys,
+      };
+      this.labelValues = {
+        ...this.labelValues,
+        [name]: values,
+      };
+    } catch (e) {
+      console.error(e);
+    }
+  }
+}

+ 0 - 3
public/app/features/explore/utils/prometheus.ts → public/app/plugins/datasource/prometheus/language_utils.ts

@@ -23,9 +23,6 @@ export function processLabels(labels, withName = false) {
   return { values, keys: Object.keys(values) };
 }
 
-// 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;

+ 0 - 0
public/app/features/explore/slate-plugins/prism/promql.ts → public/app/plugins/datasource/prometheus/promql.ts


+ 3 - 1
public/app/plugins/datasource/prometheus/query_hints.ts

@@ -1,6 +1,8 @@
 import _ from 'lodash';
 
-export function getQueryHints(query: string, series?: any[], datasource?: any): any[] {
+import { QueryHint } from 'app/types/explore';
+
+export function getQueryHints(query: string, series?: any[], datasource?: any): QueryHint[] {
   const hints = [];
 
   // ..._bucket metric needs a histogram_quantile()

+ 202 - 0
public/app/plugins/datasource/prometheus/specs/language_provider.test.ts

@@ -0,0 +1,202 @@
+import Plain from 'slate-plain-serializer';
+
+import LanguageProvider from '../language_provider';
+
+describe('Language completion provider', () => {
+  const datasource = {
+    metadataRequest: () => ({ data: { data: [] } }),
+  };
+
+  it('returns default suggestions on emtpty context', () => {
+    const instance = new LanguageProvider(datasource);
+    const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: [] });
+    expect(result.context).toBeUndefined();
+    expect(result.refresher).toBeUndefined();
+    expect(result.suggestions.length).toEqual(2);
+  });
+
+  describe('range suggestions', () => {
+    it('returns range suggestions in range context', () => {
+      const instance = new LanguageProvider(datasource);
+      const result = instance.provideCompletionItems({ text: '1', prefix: '1', wrapperClasses: ['context-range'] });
+      expect(result.context).toBe('context-range');
+      expect(result.refresher).toBeUndefined();
+      expect(result.suggestions).toEqual([
+        {
+          items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }],
+          label: 'Range vector',
+        },
+      ]);
+    });
+  });
+
+  describe('metric suggestions', () => {
+    it('returns metrics suggestions by default', () => {
+      const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
+      const result = instance.provideCompletionItems({ text: 'a', prefix: 'a', wrapperClasses: [] });
+      expect(result.context).toBeUndefined();
+      expect(result.refresher).toBeUndefined();
+      expect(result.suggestions.length).toEqual(2);
+    });
+
+    it('returns default suggestions after a binary operator', () => {
+      const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
+      const result = instance.provideCompletionItems({ text: '*', prefix: '', wrapperClasses: [] });
+      expect(result.context).toBeUndefined();
+      expect(result.refresher).toBeUndefined();
+      expect(result.suggestions.length).toEqual(2);
+    });
+  });
+
+  describe('label suggestions', () => {
+    it('returns default label suggestions on label context and no metric', () => {
+      const instance = new LanguageProvider(datasource);
+      const value = Plain.deserialize('{}');
+      const range = value.selection.merge({
+        anchorOffset: 1,
+      });
+      const valueWithSelection = value.change().select(range).value;
+      const result = instance.provideCompletionItems({
+        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 = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } });
+      const value = Plain.deserialize('metric{}');
+      const range = value.selection.merge({
+        anchorOffset: 7,
+      });
+      const valueWithSelection = value.change().select(range).value;
+      const result = instance.provideCompletionItems({
+        text: '',
+        prefix: '',
+        wrapperClasses: ['context-labels'],
+        value: valueWithSelection,
+      });
+      expect(result.context).toBe('context-labels');
+      expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
+    });
+
+    it('returns label suggestions on label context but leaves out labels that already exist', () => {
+      const instance = new LanguageProvider(datasource, {
+        labelKeys: { '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] },
+      });
+      const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}');
+      const range = value.selection.merge({
+        anchorOffset: 36,
+      });
+      const valueWithSelection = value.change().select(range).value;
+      const result = instance.provideCompletionItems({
+        text: '',
+        prefix: '',
+        wrapperClasses: ['context-labels'],
+        value: valueWithSelection,
+      });
+      expect(result.context).toBe('context-labels');
+      expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
+    });
+
+    it('returns label value suggestions inside a label value context after a negated matching operator', () => {
+      const instance = new LanguageProvider(datasource, {
+        labelKeys: { '{}': ['label'] },
+        labelValues: { '{}': { label: ['a', 'b', 'c'] } },
+      });
+      const value = Plain.deserialize('{label!=}');
+      const range = value.selection.merge({ anchorOffset: 8 });
+      const valueWithSelection = value.change().select(range).value;
+      const result = instance.provideCompletionItems({
+        text: '!=',
+        prefix: '',
+        wrapperClasses: ['context-labels'],
+        labelKey: 'label',
+        value: valueWithSelection,
+      });
+      expect(result.context).toBe('context-label-values');
+      expect(result.suggestions).toEqual([
+        {
+          items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }],
+          label: 'Label values for "label"',
+        },
+      ]);
+    });
+
+    it('returns a refresher on label context and unavailable metric', () => {
+      const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="foo"}': ['bar'] } });
+      const value = Plain.deserialize('metric{}');
+      const range = value.selection.merge({
+        anchorOffset: 7,
+      });
+      const valueWithSelection = value.change().select(range).value;
+      const result = instance.provideCompletionItems({
+        text: '',
+        prefix: '',
+        wrapperClasses: ['context-labels'],
+        value: valueWithSelection,
+      });
+      expect(result.context).toBeUndefined();
+      expect(result.refresher).toBeInstanceOf(Promise);
+      expect(result.suggestions).toEqual([]);
+    });
+
+    it('returns label values on label context when given a metric and a label key', () => {
+      const instance = new LanguageProvider(datasource, {
+        labelKeys: { '{__name__="metric"}': ['bar'] },
+        labelValues: { '{__name__="metric"}': { bar: ['baz'] } },
+      });
+      const value = Plain.deserialize('metric{bar=ba}');
+      const range = value.selection.merge({
+        anchorOffset: 13,
+      });
+      const valueWithSelection = value.change().select(range).value;
+      const result = instance.provideCompletionItems({
+        text: '=ba',
+        prefix: 'ba',
+        wrapperClasses: ['context-labels'],
+        labelKey: 'bar',
+        value: valueWithSelection,
+      });
+      expect(result.context).toBe('context-label-values');
+      expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]);
+    });
+
+    it('returns label suggestions on aggregation context and metric w/ selector', () => {
+      const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric",foo="xx"}': ['bar'] } });
+      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.provideCompletionItems({
+        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 = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } });
+      const value = Plain.deserialize('sum(metric) by ()');
+      const range = value.selection.merge({
+        anchorOffset: 16,
+      });
+      const valueWithSelection = value.change().select(range).value;
+      const result = instance.provideCompletionItems({
+        text: '',
+        prefix: '',
+        wrapperClasses: ['context-aggregation'],
+        value: valueWithSelection,
+      });
+      expect(result.context).toBe('context-aggregation');
+      expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
+    });
+  });
+});

+ 1 - 1
public/app/features/explore/utils/prometheus.test.ts → public/app/plugins/datasource/prometheus/specs/language_utils.test.ts

@@ -1,4 +1,4 @@
-import { parseSelector } from './prometheus';
+import { parseSelector } from '../language_utils';
 
 describe('parseSelector()', () => {
   let parsed;

+ 110 - 1
public/app/types/explore.ts

@@ -1,3 +1,75 @@
+import { Value } from 'slate';
+
+export interface CompletionItem {
+  /**
+   * The label of this completion item. By default
+   * this is also the text that is inserted when selecting
+   * this completion.
+   */
+  label: string;
+  /**
+   * The kind of this completion item. Based on the kind
+   * an icon is chosen by the editor.
+   */
+  kind?: string;
+  /**
+   * A human-readable string with additional information
+   * about this item, like type or symbol information.
+   */
+  detail?: string;
+  /**
+   * A human-readable string, can be Markdown, that represents a doc-comment.
+   */
+  documentation?: string;
+  /**
+   * A string that should be used when comparing this item
+   * with other items. When `falsy` the `label` is used.
+   */
+  sortText?: string;
+  /**
+   * A string that should be used when filtering a set of
+   * completion items. When `falsy` the `label` is used.
+   */
+  filterText?: string;
+  /**
+   * A string or snippet that should be inserted in a document when selecting
+   * this completion. When `falsy` the `label` is used.
+   */
+  insertText?: string;
+  /**
+   * Delete number of characters before the caret position,
+   * by default the letters from the beginning of the word.
+   */
+  deleteBackwards?: number;
+  /**
+   * Number of steps to move after the insertion, can be negative.
+   */
+  move?: number;
+}
+
+export interface CompletionItemGroup {
+  /**
+   * Label that will be displayed for all entries of this group.
+   */
+  label: string;
+  /**
+   * List of suggestions of this group.
+   */
+  items: CompletionItem[];
+  /**
+   * If true, match only by prefix (and not mid-word).
+   */
+  prefixMatch?: boolean;
+  /**
+   * If true, do not filter items in this group based on the search.
+   */
+  skipFilter?: boolean;
+  /**
+   * If true, do not sort items.
+   */
+  skipSort?: boolean;
+}
+
 interface ExploreDatasource {
   value: string;
   label: string;
@@ -8,6 +80,26 @@ export interface HistoryItem {
   query: string;
 }
 
+export abstract class LanguageProvider {
+  datasource: any;
+  request: (url) => Promise<any>;
+  start: () => Promise<any>;
+}
+
+export interface TypeaheadInput {
+  text: string;
+  prefix: string;
+  wrapperClasses: string[];
+  labelKey?: string;
+  value?: Value;
+}
+
+export interface TypeaheadOutput {
+  context?: string;
+  refresher?: Promise<{}>;
+  suggestions: CompletionItemGroup[];
+}
+
 export interface Range {
   from: string;
   to: string;
@@ -18,11 +110,28 @@ export interface Query {
   key?: string;
 }
 
+export interface QueryFix {
+  type: string;
+  label: string;
+  action?: QueryFixAction;
+}
+
+export interface QueryFixAction {
+  type: string;
+  query?: string;
+}
+
+export interface QueryHint {
+  type: string;
+  label: string;
+  fix?: QueryFix;
+}
+
 export interface QueryTransaction {
   id: string;
   done: boolean;
   error?: string;
-  hints?: any[];
+  hints?: QueryHint[];
   latency: number;
   options: any;
   query: string;