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

Explore: move suggestions logic to datasource language provider
This commit is contained in:
David
2018-10-26 11:40:52 +02:00
committed by GitHub
14 changed files with 737 additions and 684 deletions

View File

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

View File

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

View File

@@ -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<PromQueryFieldProps, PromQueryFieldState> {
plugins: any[];
languageProvider: any;
constructor(props: PromQueryFieldProps, context) {
super(props, context);
if (props.datasource.languageProvider) {
this.languageProvider = props.datasource.languageProvider;
}
this.plugins = [
BracesPlugin(),
RunnerPlugin({ handler: props.onPressEnter }),
@@ -185,26 +129,16 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
];
this.state = {
histogramMetrics: props.histogramMetrics || [],
labelKeys: props.labelKeys || {},
labelValues: props.labelValues || {},
logLabelOptions: [],
metrics: props.metrics || [],
metricsByPrefix: props.metricsByPrefix || [],
metricsByPrefix: [],
metricsOptions: [],
syntaxLoaded: false,
};
}
componentDidMount() {
// Temporarily reused by logging
const { supportsLogs } = this.props;
if (supportsLogs) {
this.fetchLogLabels();
} else {
// Usual actions
this.fetchMetricNames();
this.fetchHistogramMetrics();
if (this.languageProvider) {
this.languageProvider.start().then(() => this.onReceiveMetrics());
}
}
@@ -262,15 +196,19 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
};
onReceiveMetrics = () => {
const { histogramMetrics, metrics, metricsByPrefix } = this.state;
const { histogramMetrics, metrics } = this.languageProvider;
if (!metrics) {
return;
}
// Update global prism config
setPrismTokens(PRISM_SYNTAX, METRIC_MARK, metrics);
Prism.languages[PRISM_SYNTAX] = this.languageProvider.getSyntax();
Prism.languages[PRISM_SYNTAX][METRIC_MARK] = {
alias: 'variable',
pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`),
};
// Build metrics tree
const metricsByPrefix = groupMetricsByPrefix(metrics);
const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
const metricsOptions = [
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
@@ -281,6 +219,11 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
};
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
if (!this.languageProvider) {
return { suggestions: [] };
}
const { history } = this.props;
const { prefix, text, value, wrapperNode } = typeahead;
// Get DOM-dependent context
@@ -289,279 +232,20 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
const labelKey = labelKeyNode && labelKeyNode.textContent;
const nextChar = getNextCharacter();
const result = this.getTypeahead({ text, value, prefix, wrapperClasses, labelKey });
const result = this.languageProvider.provideCompletionItems(
{ text, value, prefix, wrapperClasses, labelKey },
{ history }
);
console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
return result;
};
// Keep this DOM-free for testing
getTypeahead({ prefix, wrapperClasses, text }: PromTypeaheadInput): TypeaheadOutput {
// Syntax spans have 3 classes by default. More indicate a recognized token
const tokenRecognized = wrapperClasses.length > 3;
// Determine candidates by CSS context
if (_.includes(wrapperClasses, 'context-range')) {
// Suggestions for metric[|]
return this.getRangeTypeahead();
} else if (_.includes(wrapperClasses, 'context-labels')) {
// Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
return this.getLabelTypeahead.apply(this, arguments);
} else if (_.includes(wrapperClasses, 'context-aggregation')) {
return this.getAggregationTypeahead.apply(this, arguments);
} else if (
// Show default suggestions in a couple of scenarios
(prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
(prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
text.match(/[+\-*/^%]/) // Anything after binary operator
) {
return this.getEmptyTypeahead();
}
return {
suggestions: [],
};
}
getEmptyTypeahead(): TypeaheadOutput {
const { history } = this.props;
const { metrics } = this.state;
const suggestions: SuggestionGroup[] = [];
if (history && history.length > 0) {
const historyItems = _.chain(history)
.uniqBy('query')
.take(HISTORY_ITEM_COUNT)
.map(h => h.query)
.map(wrapLabel)
.map(item => addHistoryMetadata(item, history))
.value();
suggestions.push({
prefixMatch: true,
skipSort: true,
label: 'History',
items: historyItems,
});
}
suggestions.push({
prefixMatch: true,
label: 'Functions',
items: FUNCTIONS.map(setFunctionMove),
});
if (metrics) {
suggestions.push({
label: 'Metrics',
items: metrics.map(wrapLabel),
});
}
return { suggestions };
}
getRangeTypeahead(): TypeaheadOutput {
return {
context: 'context-range',
suggestions: [
{
label: 'Range vector',
items: [...RATE_RANGES].map(wrapLabel),
},
],
};
}
getAggregationTypeahead({ value }: PromTypeaheadInput): TypeaheadOutput {
let refresher: Promise<any> = null;
const suggestions: SuggestionGroup[] = [];
// sum(foo{bar="1"}) by (|)
const line = value.anchorBlock.getText();
const cursorOffset: number = value.anchorOffset;
// sum(foo{bar="1"}) by (
const leftSide = line.slice(0, cursorOffset);
const openParensAggregationIndex = leftSide.lastIndexOf('(');
const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('(');
const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
// foo{bar="1"}
const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
const selector = parseSelector(selectorString, selectorString.length - 2).selector;
const labelKeys = this.state.labelKeys[selector];
if (labelKeys) {
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
} else {
refresher = this.fetchSeriesLabels(selector);
}
return {
refresher,
suggestions,
context: 'context-aggregation',
};
}
getLabelTypeahead({ text, wrapperClasses, labelKey, value }: PromTypeaheadInput): TypeaheadOutput {
let context: string;
let refresher: Promise<any> = null;
const suggestions: SuggestionGroup[] = [];
const line = value.anchorBlock.getText();
const cursorOffset: number = value.anchorOffset;
// Get normalized selector
let selector;
let parsedSelector;
try {
parsedSelector = parseSelector(line, cursorOffset);
selector = parsedSelector.selector;
} catch {
selector = EMPTY_SELECTOR;
}
const containsMetric = selector.indexOf('__name__=') > -1;
const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
// Label values
if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) {
const labelValues = this.state.labelValues[selector][labelKey];
context = 'context-label-values';
suggestions.push({
label: `Label values for "${labelKey}"`,
items: labelValues.map(wrapLabel),
});
}
} else {
// Label keys
const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
if (labelKeys) {
const possibleKeys = _.difference(labelKeys, existingKeys);
if (possibleKeys.length > 0) {
context = 'context-labels';
suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
}
}
}
// Query labels for selector
// Temporarily add skip for logging
if (selector && !this.state.labelValues[selector] && !this.props.supportsLogs) {
if (selector === EMPTY_SELECTOR) {
// Query label values for default labels
refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
} else {
refresher = this.fetchSeriesLabels(selector, !containsMetric);
}
}
return { context, refresher, suggestions };
}
request = url => {
if (this.props.request) {
return this.props.request(url);
}
return fetch(url);
};
fetchHistogramMetrics() {
this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true, () => {
const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR];
if (histogramSeries && histogramSeries['__name__']) {
const histogramMetrics = histogramSeries['__name__'].slice().sort();
this.setState({ histogramMetrics }, this.onReceiveMetrics);
}
});
}
// Temporarily here while reusing this field for logging
async fetchLogLabels() {
const url = '/api/prom/label';
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const labelKeys = body.data.slice().sort();
const labelKeysBySelector = {
...this.state.labelKeys,
[EMPTY_SELECTOR]: labelKeys,
};
const labelValuesByKey = {};
const logLabelOptions = [];
for (const key of labelKeys) {
const valuesUrl = `/api/prom/label/${key}/values`;
const res = await this.request(valuesUrl);
const body = await (res.data || res.json());
const values = body.data.slice().sort();
labelValuesByKey[key] = values;
logLabelOptions.push({
label: key,
value: key,
children: values.map(value => ({ label: value, value })),
});
}
const labelValues = { [EMPTY_SELECTOR]: labelValuesByKey };
this.setState({ labelKeys: labelKeysBySelector, labelValues, logLabelOptions });
} catch (e) {
console.error(e);
}
}
async fetchLabelValues(key: string) {
const url = `/api/v1/label/${key}/values`;
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const exisingValues = this.state.labelValues[EMPTY_SELECTOR];
const values = {
...exisingValues,
[key]: body.data,
};
const labelValues = {
...this.state.labelValues,
[EMPTY_SELECTOR]: values,
};
this.setState({ labelValues });
} catch (e) {
console.error(e);
}
}
async fetchSeriesLabels(name: string, withName?: boolean, callback?: () => void) {
const url = `/api/v1/series?match[]=${name}`;
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const { keys, values } = processLabels(body.data, withName);
const labelKeys = {
...this.state.labelKeys,
[name]: keys,
};
const labelValues = {
...this.state.labelValues,
[name]: values,
};
this.setState({ labelKeys, labelValues }, callback);
} catch (e) {
console.error(e);
}
}
async fetchMetricNames() {
const url = '/api/v1/label/__name__/values';
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const metrics = body.data;
const metricsByPrefix = groupMetricsByPrefix(metrics);
this.setState({ metrics, metricsByPrefix }, this.onReceiveMetrics);
} catch (error) {
console.error(error);
}
}
render() {
const { error, hint, initialQuery, supportsLogs } = this.props;
const { logLabelOptions, metricsOptions, syntaxLoaded } = this.state;
const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
return (
<div className="prom-query-field">

View File

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

View File

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

View File

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

View File

@@ -1,424 +0,0 @@
/* tslint:disable max-line-length */
export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without'];
const AGGREGATION_OPERATORS = [
{
label: 'sum',
insertText: 'sum()',
documentation: 'Calculate sum over dimensions',
},
{
label: 'min',
insertText: 'min()',
documentation: 'Select minimum over dimensions',
},
{
label: 'max',
insertText: 'max()',
documentation: 'Select maximum over dimensions',
},
{
label: 'avg',
insertText: 'avg()',
documentation: 'Calculate the average over dimensions',
},
{
label: 'stddev',
insertText: 'stddev()',
documentation: 'Calculate population standard deviation over dimensions',
},
{
label: 'stdvar',
insertText: 'stdvar()',
documentation: 'Calculate population standard variance over dimensions',
},
{
label: 'count',
insertText: 'count()',
documentation: 'Count number of elements in the vector',
},
{
label: 'count_values',
insertText: 'count_values()',
documentation: 'Count number of elements with the same value',
},
{
label: 'bottomk',
insertText: 'bottomk()',
documentation: 'Smallest k elements by sample value',
},
{
label: 'topk',
insertText: 'topk()',
documentation: 'Largest k elements by sample value',
},
{
label: 'quantile',
insertText: 'quantile()',
documentation: 'Calculate φ-quantile (0 ≤ φ ≤ 1) over dimensions',
},
];
export const FUNCTIONS = [
...AGGREGATION_OPERATORS,
{
insertText: 'abs()',
label: 'abs',
detail: 'abs(v instant-vector)',
documentation: 'Returns the input vector with all sample values converted to their absolute value.',
},
{
insertText: 'absent()',
label: 'absent',
detail: 'absent(v instant-vector)',
documentation:
'Returns an empty vector if the vector passed to it has any elements and a 1-element vector with the value 1 if the vector passed to it has no elements. This is useful for alerting on when no time series exist for a given metric name and label combination.',
},
{
insertText: 'ceil()',
label: 'ceil',
detail: 'ceil(v instant-vector)',
documentation: 'Rounds the sample values of all elements in `v` up to the nearest integer.',
},
{
insertText: 'changes()',
label: 'changes',
detail: 'changes(v range-vector)',
documentation:
'For each input time series, `changes(v range-vector)` returns the number of times its value has changed within the provided time range as an instant vector.',
},
{
insertText: 'clamp_max()',
label: 'clamp_max',
detail: 'clamp_max(v instant-vector, max scalar)',
documentation: 'Clamps the sample values of all elements in `v` to have an upper limit of `max`.',
},
{
insertText: 'clamp_min()',
label: 'clamp_min',
detail: 'clamp_min(v instant-vector, min scalar)',
documentation: 'Clamps the sample values of all elements in `v` to have a lower limit of `min`.',
},
{
insertText: 'count_scalar()',
label: 'count_scalar',
detail: 'count_scalar(v instant-vector)',
documentation:
'Returns the number of elements in a time series vector as a scalar. This is in contrast to the `count()` aggregation operator, which always returns a vector (an empty one if the input vector is empty) and allows grouping by labels via a `by` clause.',
},
{
insertText: 'day_of_month()',
label: 'day_of_month',
detail: 'day_of_month(v=vector(time()) instant-vector)',
documentation: 'Returns the day of the month for each of the given times in UTC. Returned values are from 1 to 31.',
},
{
insertText: 'day_of_week()',
label: 'day_of_week',
detail: 'day_of_week(v=vector(time()) instant-vector)',
documentation:
'Returns the day of the week for each of the given times in UTC. Returned values are from 0 to 6, where 0 means Sunday etc.',
},
{
insertText: 'days_in_month()',
label: 'days_in_month',
detail: 'days_in_month(v=vector(time()) instant-vector)',
documentation:
'Returns number of days in the month for each of the given times in UTC. Returned values are from 28 to 31.',
},
{
insertText: 'delta()',
label: 'delta',
detail: 'delta(v range-vector)',
documentation:
'Calculates the difference between the first and last value of each time series element in a range vector `v`, returning an instant vector with the given deltas and equivalent labels. The delta is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if the sample values are all integers.',
},
{
insertText: 'deriv()',
label: 'deriv',
detail: 'deriv(v range-vector)',
documentation:
'Calculates the per-second derivative of the time series in a range vector `v`, using simple linear regression.',
},
{
insertText: 'drop_common_labels()',
label: 'drop_common_labels',
detail: 'drop_common_labels(instant-vector)',
documentation: 'Drops all labels that have the same name and value across all series in the input vector.',
},
{
insertText: 'exp()',
label: 'exp',
detail: 'exp(v instant-vector)',
documentation:
'Calculates the exponential function for all elements in `v`.\nSpecial cases are:\n* `Exp(+Inf) = +Inf` \n* `Exp(NaN) = NaN`',
},
{
insertText: 'floor()',
label: 'floor',
detail: 'floor(v instant-vector)',
documentation: 'Rounds the sample values of all elements in `v` down to the nearest integer.',
},
{
insertText: 'histogram_quantile()',
label: 'histogram_quantile',
detail: 'histogram_quantile(φ float, b instant-vector)',
documentation:
'Calculates the φ-quantile (0 ≤ φ ≤ 1) from the buckets `b` of a histogram. The samples in `b` are the counts of observations in each bucket. Each sample must have a label `le` where the label value denotes the inclusive upper bound of the bucket. (Samples without such a label are silently ignored.) The histogram metric type automatically provides time series with the `_bucket` suffix and the appropriate labels.',
},
{
insertText: 'holt_winters()',
label: 'holt_winters',
detail: 'holt_winters(v range-vector, sf scalar, tf scalar)',
documentation:
'Produces a smoothed value for time series based on the range in `v`. The lower the smoothing factor `sf`, the more importance is given to old data. The higher the trend factor `tf`, the more trends in the data is considered. Both `sf` and `tf` must be between 0 and 1.',
},
{
insertText: 'hour()',
label: 'hour',
detail: 'hour(v=vector(time()) instant-vector)',
documentation: 'Returns the hour of the day for each of the given times in UTC. Returned values are from 0 to 23.',
},
{
insertText: 'idelta()',
label: 'idelta',
detail: 'idelta(v range-vector)',
documentation:
'Calculates the difference between the last two samples in the range vector `v`, returning an instant vector with the given deltas and equivalent labels.',
},
{
insertText: 'increase()',
label: 'increase',
detail: 'increase(v range-vector)',
documentation:
'Calculates the increase in the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. The increase is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if a counter increases only by integer increments.',
},
{
insertText: 'irate()',
label: 'irate',
detail: 'irate(v range-vector)',
documentation:
'Calculates the per-second instant rate of increase of the time series in the range vector. This is based on the last two data points. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for.',
},
{
insertText: 'label_replace()',
label: 'label_replace',
detail: 'label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)',
documentation:
"For each timeseries in `v`, `label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)` matches the regular expression `regex` against the label `src_label`. If it matches, then the timeseries is returned with the label `dst_label` replaced by the expansion of `replacement`. `$1` is replaced with the first matching subgroup, `$2` with the second etc. If the regular expression doesn't match then the timeseries is returned unchanged.",
},
{
insertText: 'ln()',
label: 'ln',
detail: 'ln(v instant-vector)',
documentation:
'calculates the natural logarithm for all elements in `v`.\nSpecial cases are:\n * `ln(+Inf) = +Inf`\n * `ln(0) = -Inf`\n * `ln(x < 0) = NaN`\n * `ln(NaN) = NaN`',
},
{
insertText: 'log2()',
label: 'log2',
detail: 'log2(v instant-vector)',
documentation:
'Calculates the binary logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.',
},
{
insertText: 'log10()',
label: 'log10',
detail: 'log10(v instant-vector)',
documentation:
'Calculates the decimal logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.',
},
{
insertText: 'minute()',
label: 'minute',
detail: 'minute(v=vector(time()) instant-vector)',
documentation:
'Returns the minute of the hour for each of the given times in UTC. Returned values are from 0 to 59.',
},
{
insertText: 'month()',
label: 'month',
detail: 'month(v=vector(time()) instant-vector)',
documentation:
'Returns the month of the year for each of the given times in UTC. Returned values are from 1 to 12, where 1 means January etc.',
},
{
insertText: 'predict_linear()',
label: 'predict_linear',
detail: 'predict_linear(v range-vector, t scalar)',
documentation:
'Predicts the value of time series `t` seconds from now, based on the range vector `v`, using simple linear regression.',
},
{
insertText: 'rate()',
label: 'rate',
detail: 'rate(v range-vector)',
documentation:
"Calculates the per-second average rate of increase of the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. Also, the calculation extrapolates to the ends of the time range, allowing for missed scrapes or imperfect alignment of scrape cycles with the range's time period.",
},
{
insertText: 'resets()',
label: 'resets',
detail: 'resets(v range-vector)',
documentation:
'For each input time series, `resets(v range-vector)` returns the number of counter resets within the provided time range as an instant vector. Any decrease in the value between two consecutive samples is interpreted as a counter reset.',
},
{
insertText: 'round()',
label: 'round',
detail: 'round(v instant-vector, to_nearest=1 scalar)',
documentation:
'Rounds the sample values of all elements in `v` to the nearest integer. Ties are resolved by rounding up. The optional `to_nearest` argument allows specifying the nearest multiple to which the sample values should be rounded. This multiple may also be a fraction.',
},
{
insertText: 'scalar()',
label: 'scalar',
detail: 'scalar(v instant-vector)',
documentation:
'Given a single-element input vector, `scalar(v instant-vector)` returns the sample value of that single element as a scalar. If the input vector does not have exactly one element, `scalar` will return `NaN`.',
},
{
insertText: 'sort()',
label: 'sort',
detail: 'sort(v instant-vector)',
documentation: 'Returns vector elements sorted by their sample values, in ascending order.',
},
{
insertText: 'sort_desc()',
label: 'sort_desc',
detail: 'sort_desc(v instant-vector)',
documentation: 'Returns vector elements sorted by their sample values, in descending order.',
},
{
insertText: 'sqrt()',
label: 'sqrt',
detail: 'sqrt(v instant-vector)',
documentation: 'Calculates the square root of all elements in `v`.',
},
{
insertText: 'time()',
label: 'time',
detail: 'time()',
documentation:
'Returns the number of seconds since January 1, 1970 UTC. Note that this does not actually return the current time, but the time at which the expression is to be evaluated.',
},
{
insertText: 'vector()',
label: 'vector',
detail: 'vector(s scalar)',
documentation: 'Returns the scalar `s` as a vector with no labels.',
},
{
insertText: 'year()',
label: 'year',
detail: 'year(v=vector(time()) instant-vector)',
documentation: 'Returns the year for each of the given times in UTC.',
},
{
insertText: 'avg_over_time()',
label: 'avg_over_time',
detail: 'avg_over_time(range-vector)',
documentation: 'The average value of all points in the specified interval.',
},
{
insertText: 'min_over_time()',
label: 'min_over_time',
detail: 'min_over_time(range-vector)',
documentation: 'The minimum value of all points in the specified interval.',
},
{
insertText: 'max_over_time()',
label: 'max_over_time',
detail: 'max_over_time(range-vector)',
documentation: 'The maximum value of all points in the specified interval.',
},
{
insertText: 'sum_over_time()',
label: 'sum_over_time',
detail: 'sum_over_time(range-vector)',
documentation: 'The sum of all values in the specified interval.',
},
{
insertText: 'count_over_time()',
label: 'count_over_time',
detail: 'count_over_time(range-vector)',
documentation: 'The count of all values in the specified interval.',
},
{
insertText: 'quantile_over_time()',
label: 'quantile_over_time',
detail: 'quantile_over_time(scalar, range-vector)',
documentation: 'The φ-quantile (0 ≤ φ ≤ 1) of the values in the specified interval.',
},
{
insertText: 'stddev_over_time()',
label: 'stddev_over_time',
detail: 'stddev_over_time(range-vector)',
documentation: 'The population standard deviation of the values in the specified interval.',
},
{
insertText: 'stdvar_over_time()',
label: 'stdvar_over_time',
detail: 'stdvar_over_time(range-vector)',
documentation: 'The population standard variance of the values in the specified interval.',
},
];
const tokenizer = {
comment: {
pattern: /(^|[^\n])#.*/,
lookbehind: true,
},
'context-aggregation': {
pattern: /((by|without)\s*)\([^)]*\)/, // by ()
lookbehind: true,
inside: {
'label-key': {
pattern: /[^,\s][^,]*[^,\s]*/,
alias: 'attr-name',
},
},
},
'context-labels': {
pattern: /\{[^}]*(?=})/,
inside: {
'label-key': {
pattern: /[a-z_]\w*(?=\s*(=|!=|=~|!~))/,
alias: 'attr-name',
},
'label-value': {
pattern: /"(?:\\.|[^\\"])*"/,
greedy: true,
alias: 'attr-value',
},
},
},
function: new RegExp(`\\b(?:${FUNCTIONS.map(f => f.label).join('|')})(?=\\s*\\()`, 'i'),
'context-range': [
{
pattern: /\[[^\]]*(?=])/, // [1m]
inside: {
'range-duration': {
pattern: /\b\d+[smhdwy]\b/i,
alias: 'number',
},
},
},
{
pattern: /(offset\s+)\w+/, // offset 1m
lookbehind: true,
inside: {
'range-duration': {
pattern: /\b\d+[smhdwy]\b/i,
alias: 'number',
},
},
},
],
number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/,
operator: new RegExp(`/[-+*/=%^~]|&&?|\\|?\\||!=?|<(?:=>?|<|>)?|>[>=]?|\\b(?:${OPERATORS.join('|')})\\b`, 'i'),
punctuation: /[{};()`,.]/,
};
export default tokenizer;

View File

@@ -1,64 +0,0 @@
import { parseSelector } from './prometheus';
describe('parseSelector()', () => {
let parsed;
it('returns a clean selector from an empty selector', () => {
parsed = parseSelector('{}', 1);
expect(parsed.selector).toBe('{}');
expect(parsed.labelKeys).toEqual([]);
});
it('throws if selector is broken', () => {
expect(() => parseSelector('{foo')).toThrow();
});
it('returns the selector sorted by label key', () => {
parsed = parseSelector('{foo="bar"}');
expect(parsed.selector).toBe('{foo="bar"}');
expect(parsed.labelKeys).toEqual(['foo']);
parsed = parseSelector('{foo="bar",baz="xx"}');
expect(parsed.selector).toBe('{baz="xx",foo="bar"}');
});
it('returns a clean selector from an incomplete one', () => {
parsed = parseSelector('{foo}');
expect(parsed.selector).toBe('{}');
parsed = parseSelector('{foo="bar",baz}');
expect(parsed.selector).toBe('{foo="bar"}');
parsed = parseSelector('{foo="bar",baz="}');
expect(parsed.selector).toBe('{foo="bar"}');
});
it('throws if not inside a selector', () => {
expect(() => parseSelector('foo{}', 0)).toThrow();
expect(() => parseSelector('foo{} + bar{}', 5)).toThrow();
});
it('returns the selector nearest to the cursor offset', () => {
expect(() => parseSelector('{foo="bar"} + {foo="bar"}', 0)).toThrow();
parsed = parseSelector('{foo="bar"} + {foo="bar"}', 1);
expect(parsed.selector).toBe('{foo="bar"}');
parsed = parseSelector('{foo="bar"} + {baz="xx"}', 1);
expect(parsed.selector).toBe('{foo="bar"}');
parsed = parseSelector('{baz="xx"} + {foo="bar"}', 16);
expect(parsed.selector).toBe('{foo="bar"}');
});
it('returns a selector with metric if metric is given', () => {
parsed = parseSelector('bar{foo}', 4);
expect(parsed.selector).toBe('{__name__="bar"}');
parsed = parseSelector('baz{foo="bar"}', 12);
expect(parsed.selector).toBe('{__name__="baz",foo="bar"}');
parsed = parseSelector('bar:metric:1m{}', 14);
expect(parsed.selector).toBe('{__name__="bar:metric:1m"}');
});
});

View File

@@ -1,88 +0,0 @@
export const RATE_RANGES = ['1m', '5m', '10m', '30m', '1h'];
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] = [];
}
if (values[key].indexOf(rest[key]) === -1) {
values[key].push(rest[key]);
}
});
});
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;
export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any[]; selector: string } {
if (!query.match(selectorRegexp)) {
// Special matcher for metrics
if (query.match(/^[A-Za-z:][\w:]*$/)) {
return {
selector: `{__name__="${query}"}`,
labelKeys: ['__name__'],
};
}
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);
const labels = {};
selector.replace(labelRegexp, (_, key, operator, value) => {
labels[key] = { value, operator };
return '';
});
// Add metric if there is one before the selector
const metricPrefix = query.slice(0, prefixOpen);
const metricMatch = metricPrefix.match(/[A-Za-z:][\w:]*$/);
if (metricMatch) {
labels['__name__'] = { value: `"${metricMatch[0]}"`, operator: '=' };
}
// Build sorted selector
const labelKeys = Object.keys(labels).sort();
const cleanSelector = labelKeys.map(key => `${key}${labels[key].operator}${labels[key].value}`).join(',');
const selectorString = ['{', cleanSelector, '}'].join('');
return { labelKeys, selector: selectorString };
}