From 54a3e2d1d1d710dc9f7be4b831a31a7810c71186 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 24 Oct 2018 14:55:56 +0200 Subject: [PATCH 1/2] Added types to query rows --- public/app/features/explore/QueryRows.tsx | 40 ++++++++++++++++--- .../datasource/prometheus/query_hints.ts | 4 +- public/app/types/explore.ts | 19 ++++++++- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx index 0b0d7085d2d..4024022851e 100644 --- a/public/app/features/explore/QueryRows.tsx +++ b/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 { +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; + history: HistoryItem[]; + request: (url: string) => Promise; + // Temporarily + supportsLogs?: boolean; + transactions: QueryTransaction[]; +} + +type QueryRowProps = QueryRowCommonProps & + QueryRowEventHandlers & { + index: number; + query: string; + }; + +class QueryRow extends PureComponent { onChangeQuery = (value, override?: boolean) => { const { index, onChangeQuery } = this.props; if (onChangeQuery) { @@ -56,7 +79,7 @@ class QueryRow extends PureComponent { render() { const { history, query, request, supportsLogs, transactions } = this.props; - const transactionWithError = transactions.find(t => t.error); + const transactionWithError = transactions.find(t => t.error !== undefined); const hint = getFirstHintFromTransactions(transactions); const queryError = transactionWithError ? transactionWithError.error : null; return ( @@ -93,9 +116,14 @@ class QueryRow extends PureComponent { } } -export default class QueryRows extends PureComponent { +type QueryRowsProps = QueryRowCommonProps & + QueryRowEventHandlers & { + queries: Query[]; + }; + +export default class QueryRows extends PureComponent { render() { - const { className = '', queries, queryHints, transactions, ...handlers } = this.props; + const { className = '', queries, transactions, ...handlers } = this.props; return (
{queries.map((q, index) => ( diff --git a/public/app/plugins/datasource/prometheus/query_hints.ts b/public/app/plugins/datasource/prometheus/query_hints.ts index cfd04c766ba..388a9be48d1 100644 --- a/public/app/plugins/datasource/prometheus/query_hints.ts +++ b/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() diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 918dd4e4483..8746dd2edf6 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -18,11 +18,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; From 6f2315d5c585d0a88d274fbf7e8b8c77422821b9 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 25 Oct 2018 12:24:24 +0200 Subject: [PATCH 2/2] Moved prom language features to datasource language provider --- public/app/features/explore/Explore.tsx | 7 +- .../features/explore/PromQueryField.test.tsx | 229 +---------- .../app/features/explore/PromQueryField.tsx | 374 ++---------------- public/app/features/explore/QueryField.tsx | 88 +---- public/app/features/explore/QueryRows.tsx | 6 +- public/app/features/explore/Typeahead.tsx | 20 +- .../datasource/prometheus/datasource.ts | 3 + .../prometheus/language_provider.ts | 334 ++++++++++++++++ .../datasource/prometheus/language_utils.ts} | 3 - .../datasource/prometheus}/promql.ts | 0 .../specs/language_provider.test.ts | 202 ++++++++++ .../prometheus/specs/language_utils.test.ts} | 2 +- public/app/types/explore.ts | 92 +++++ 13 files changed, 683 insertions(+), 677 deletions(-) create mode 100644 public/app/plugins/datasource/prometheus/language_provider.ts rename public/app/{features/explore/utils/prometheus.ts => plugins/datasource/prometheus/language_utils.ts} (96%) rename public/app/{features/explore/slate-plugins/prism => plugins/datasource/prometheus}/promql.ts (100%) create mode 100644 public/app/plugins/datasource/prometheus/specs/language_provider.test.ts rename public/app/{features/explore/utils/prometheus.test.ts => plugins/datasource/prometheus/specs/language_utils.test.ts} (97%) diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index bac063116f1..680cd1e6685 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -695,11 +695,6 @@ export class Explore extends React.PureComponent { }); } - 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 { {datasource && !datasourceError ? (
{ - const defaultProps = { - request: () => ({ data: { data: [] } }), - }; - - it('returns default suggestions on emtpty context', () => { - const instance = shallow().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().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( - - ).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( - - ).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().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( - - ).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( - - ).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( - - ).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( - - ).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( - - ).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( - - ).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( - - ).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', () => { diff --git a/public/app/features/explore/PromQueryField.tsx b/public/app/features/explore/PromQueryField.tsx index 442e51af987..58856e3116f 100644 --- a/public/app/features/explore/PromQueryField.tsx +++ b/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'; +import TypeaheadField, { TypeaheadInput, TypeaheadFieldState } from './QueryField'; -const DEFAULT_KEYS = ['job', 'instance']; -const EMPTY_SELECTOR = '{}'; 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 { 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 this.onReceiveMetrics()); } } @@ -262,15 +196,19 @@ class PromQueryField extends React.PureComponent { - 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 { + 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 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 = 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 = 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 (
diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx index ce0bcd71ed0..86daaa43eac 100644 --- a/public/app/features/explore/QueryField.tsx +++ b/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 { menuEl: HTMLElement | null; plugins: any[]; @@ -293,7 +219,7 @@ class QueryField extends React.PureComponent { + onClickMenu = (item: CompletionItem) => { // Manually triggering change const change = this.applyTypeahead(this.state.value.change(), item); this.onChange(change); diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx index 4024022851e..aaa7dfcd20b 100644 --- a/public/app/features/explore/QueryRows.tsx +++ b/public/app/features/explore/QueryRows.tsx @@ -24,8 +24,8 @@ interface QueryRowEventHandlers { interface QueryRowCommonProps { className?: string; + datasource: any; history: HistoryItem[]; - request: (url: string) => Promise; // Temporarily supportsLogs?: boolean; transactions: QueryTransaction[]; @@ -78,7 +78,7 @@ class QueryRow extends PureComponent { }; render() { - const { history, query, request, supportsLogs, transactions } = this.props; + 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; @@ -89,6 +89,7 @@ class QueryRow extends PureComponent {
{ onClickHintFix={this.onClickHintFix} onPressEnter={this.onPressEnter} onQueryChange={this.onChangeQuery} - request={request} supportsLogs={supportsLogs} />
diff --git a/public/app/features/explore/Typeahead.tsx b/public/app/features/explore/Typeahead.tsx index 0c01cbe01ba..13882e030f6 100644 --- a/public/app/features/explore/Typeahead.tsx +++ b/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 { +class TypeaheadItem extends React.PureComponent { el: HTMLElement; componentDidUpdate(prevProps) { @@ -53,14 +53,14 @@ class TypeaheadItem extends React.PureComponent { } 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 { +class TypeaheadGroup extends React.PureComponent { render() { const { items, label, selected, onClickItem, prefix } = this.props; return ( @@ -85,13 +85,13 @@ class TypeaheadGroup extends React.PureComponent { } interface TypeaheadProps { - groupedItems: SuggestionGroup[]; + groupedItems: CompletionItemGroup[]; menuRef: any; - selectedItem: Suggestion | null; + selectedItem: CompletionItem | null; onClickItem: (Suggestion) => void; prefix?: string; } -class Typeahead extends React.PureComponent { +class Typeahead extends React.PureComponent { render() { const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props; return ( diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 89f88a946c2..c2740a7c32e 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/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() { diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts new file mode 100644 index 00000000000..3e406a71264 --- /dev/null +++ b/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 = 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 = 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); + } + } +} diff --git a/public/app/features/explore/utils/prometheus.ts b/public/app/plugins/datasource/prometheus/language_utils.ts similarity index 96% rename from public/app/features/explore/utils/prometheus.ts rename to public/app/plugins/datasource/prometheus/language_utils.ts index 170c5ec8cc5..5995c427cd1 100644 --- a/public/app/features/explore/utils/prometheus.ts +++ b/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; diff --git a/public/app/features/explore/slate-plugins/prism/promql.ts b/public/app/plugins/datasource/prometheus/promql.ts similarity index 100% rename from public/app/features/explore/slate-plugins/prism/promql.ts rename to public/app/plugins/datasource/prometheus/promql.ts diff --git a/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts new file mode 100644 index 00000000000..3a46e2efaf3 --- /dev/null +++ b/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' }]); + }); + }); +}); diff --git a/public/app/features/explore/utils/prometheus.test.ts b/public/app/plugins/datasource/prometheus/specs/language_utils.test.ts similarity index 97% rename from public/app/features/explore/utils/prometheus.test.ts rename to public/app/plugins/datasource/prometheus/specs/language_utils.test.ts index 4e84deaa7e8..748217e21b7 100644 --- a/public/app/features/explore/utils/prometheus.test.ts +++ b/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; diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 8746dd2edf6..807769212f3 100644 --- a/public/app/types/explore.ts +++ b/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; + start: () => Promise; +} + +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;