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