Commit 5da3584d by David Committed by Alexander Zobnin

Explore: facetting for label completion (#12786)

* Explore: facetting for label completion

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

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