Refactor Explore query field (#12643)

* Refactor Explore query field

- extract typeahead field that only contains logic for the typeahead
  mechanics
- renamed QueryField to PromQueryField, a wrapper around TypeaheadField
  that deals with Prometheus-specific concepts
- PromQueryField creates a promql typeahead by providing the handlers
  for producing suggestions, and for applying suggestions
- The `refresher` promise is needed to trigger a render once an async
  action in the wrapper returns.

This is prep work for a composable query field to be used by Explore, as
well as editors in datasource plugins.

* Added typeahead handling tests

- extracted context-to-suggestion logic to make it testable
- kept DOM-dependent parts in main onTypeahead funtion

* simplified error handling in explore query field

* Refactor query suggestions

- use monaco's suggestion types (roughly), see
  https://github.com/Microsoft/monaco-editor/blob/f6fb545/monaco.d.ts#L4208
- suggest functions and metrics in empty field (ctrl+space)
- copy and expand prometheus function docs from prometheus datasource
  (will be migrated back to the datasource in the future)

* Added prop and state types, removed unused cwrp

* Split up suggestion processing for code readability
This commit is contained in:
David 2018-07-26 14:04:12 +02:00 committed by Alexander Zobnin
parent 1db2e869c5
commit 7699451d94
7 changed files with 1100 additions and 403 deletions

View File

@ -0,0 +1,125 @@
import React from 'react';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
import PromQueryField from './PromQueryField';
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 result = instance.getTypeahead({ text: 'j', prefix: 'j', wrapperClasses: ['context-labels'] });
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'] }} />
).instance() as PromQueryField;
const result = instance.getTypeahead({
text: 'job',
prefix: 'job',
wrapperClasses: ['context-labels'],
metric: 'foo',
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns a refresher on label context and unavailable metric', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ foo: ['bar'] }} />
).instance() as PromQueryField;
const result = instance.getTypeahead({
text: 'job',
prefix: 'job',
wrapperClasses: ['context-labels'],
metric: 'xxx',
});
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={{ foo: ['bar'] }} labelValues={{ foo: { bar: ['baz'] } }} />
).instance() as PromQueryField;
const result = instance.getTypeahead({
text: '=ba',
prefix: 'ba',
wrapperClasses: ['context-labels'],
metric: 'foo',
labelKey: 'bar',
});
expect(result.context).toBe('context-label-values');
expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values' }]);
});
it('returns label suggestions on aggregation context and metric', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ foo: ['bar'] }} />
).instance() as PromQueryField;
const result = instance.getTypeahead({
text: 'job',
prefix: 'job',
wrapperClasses: ['context-aggregation'],
metric: 'foo',
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
});
});

View File

@ -0,0 +1,340 @@
import _ from 'lodash';
import React from 'react';
// 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 TypeaheadField, {
Suggestion,
SuggestionGroup,
TypeaheadInput,
TypeaheadFieldState,
TypeaheadOutput,
} from './QueryField';
const EMPTY_METRIC = '';
const METRIC_MARK = 'metric';
const PRISM_LANGUAGE = 'promql';
export const wrapLabel = label => ({ label });
export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
suggestion.move = -1;
return suggestion;
};
export function willApplySuggestion(
suggestion: string,
{ typeaheadContext, typeaheadText }: TypeaheadFieldState
): string {
// Modify suggestion based on context
switch (typeaheadContext) {
case 'context-labels': {
const nextChar = getNextCharacter();
if (!nextChar || nextChar === '}' || nextChar === ',') {
suggestion += '=';
}
break;
}
case 'context-label-values': {
// Always add quotes and remove existing ones instead
if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
suggestion = `"${suggestion}`;
}
if (getNextCharacter() !== '"') {
suggestion = `${suggestion}"`;
}
break;
}
default:
}
return suggestion;
}
interface PromQueryFieldProps {
initialQuery?: string | null;
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
metrics?: string[];
onPressEnter?: () => void;
onQueryChange?: (value: string) => void;
portalPrefix?: string;
request?: (url: string) => any;
}
interface PromQueryFieldState {
labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
metrics: string[];
}
interface PromTypeaheadInput {
text: string;
prefix: string;
wrapperClasses: string[];
metric?: string;
labelKey?: string;
}
class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryFieldState> {
plugins: any[];
constructor(props, context) {
super(props, context);
this.plugins = [
RunnerPlugin({ handler: props.onPressEnter }),
PluginPrism({ definition: PrismPromql, language: PRISM_LANGUAGE }),
];
this.state = {
labelKeys: props.labelKeys || {},
labelValues: props.labelValues || {},
metrics: props.metrics || [],
};
}
componentDidMount() {
this.fetchMetricNames();
}
onChangeQuery = value => {
// Send text change to parent
const { onQueryChange } = this.props;
if (onQueryChange) {
onQueryChange(value);
}
};
onReceiveMetrics = () => {
if (!this.state.metrics) {
return;
}
setPrismTokens(PRISM_LANGUAGE, METRIC_MARK, this.state.metrics);
};
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
const { editorNode, prefix, text, 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 result = this.getTypeahead({ text, prefix, wrapperClasses, metric, labelKey });
console.log('handleTypeahead', wrapperClasses, text, prefix, result.context);
return result;
};
// Keep this DOM-free for testing
getTypeahead({ prefix, wrapperClasses, metric, text }: PromTypeaheadInput): TypeaheadOutput {
// 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 (metric && _.includes(wrapperClasses, 'context-aggregation')) {
return this.getAggregationTypeahead.apply(this, arguments);
} else if (
// Non-empty but not inside known token unless it's a metric
(prefix && !_.includes(wrapperClasses, 'token')) ||
prefix === metric ||
(prefix === '' && !text.match(/^[)\s]+$/)) || // Empty context or after ')'
text.match(/[+\-*/^%]/) // After binary operator
) {
return this.getEmptyTypeahead();
}
return {
suggestions: [],
};
}
getEmptyTypeahead(): TypeaheadOutput {
const suggestions: SuggestionGroup[] = [];
suggestions.push({
prefixMatch: true,
label: 'Functions',
items: FUNCTIONS.map(setFunctionMove),
});
if (this.state.metrics) {
suggestions.push({
label: 'Metrics',
items: this.state.metrics.map(wrapLabel),
});
}
return { suggestions };
}
getRangeTypeahead(): TypeaheadOutput {
return {
context: 'context-range',
suggestions: [
{
label: 'Range vector',
items: [...RATE_RANGES].map(wrapLabel),
},
],
};
}
getAggregationTypeahead({ metric }: PromTypeaheadInput): TypeaheadOutput {
let refresher: Promise<any> = null;
const suggestions: SuggestionGroup[] = [];
const labelKeys = this.state.labelKeys[metric];
if (labelKeys) {
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
} else {
refresher = this.fetchMetricLabels(metric);
}
return {
refresher,
suggestions,
context: 'context-aggregation',
};
}
getLabelTypeahead({ metric, text, wrapperClasses, labelKey }: 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);
}
} 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
context = 'context-labels';
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
}
}
return { context, refresher, suggestions };
}
request = url => {
if (this.props.request) {
return this.props.request(url);
}
return fetch(url);
};
async fetchLabelValues(key) {
const url = `/api/v1/label/${key}/values`;
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const pairs = this.state.labelValues[EMPTY_METRIC];
const values = {
...pairs,
[key]: body.data,
};
const labelValues = {
...this.state.labelValues,
[EMPTY_METRIC]: values,
};
this.setState({ labelValues });
} catch (e) {
console.error(e);
}
}
async fetchMetricLabels(name) {
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 labelKeys = {
...this.state.labelKeys,
[name]: keys,
};
const labelValues = {
...this.state.labelValues,
[name]: values,
};
this.setState({ labelKeys, labelValues });
} 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());
this.setState({ metrics: body.data }, this.onReceiveMetrics);
} catch (error) {
console.error(error);
}
}
render() {
return (
<TypeaheadField
additionalPlugins={this.plugins}
cleanText={cleanText}
initialValue={this.props.initialQuery}
onTypeahead={this.onTypeahead}
onWillApplySuggestion={willApplySuggestion}
onValueChanged={this.onChangeQuery}
placeholder="Enter a PromQL query"
/>
);
}
}
export default PromQueryField;

View File

@ -1,106 +1,163 @@
import _ from 'lodash';
import React from 'react';
import ReactDOM from 'react-dom';
import { Value } from 'slate';
import { Block, Change, Document, Text, Value } from 'slate';
import { Editor } from 'slate-react';
import Plain from 'slate-plain-serializer';
// dom also includes Element polyfills
import { getNextCharacter, getPreviousCousin } from './utils/dom';
import BracesPlugin from './slate-plugins/braces';
import ClearPlugin from './slate-plugins/clear';
import NewlinePlugin from './slate-plugins/newline';
import PluginPrism, { setPrismTokens } from './slate-plugins/prism/index';
import RunnerPlugin from './slate-plugins/runner';
import debounce from './utils/debounce';
import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
import Typeahead from './Typeahead';
const EMPTY_METRIC = '';
const METRIC_MARK = 'metric';
export const TYPEAHEAD_DEBOUNCE = 300;
function flattenSuggestions(s) {
function flattenSuggestions(s: any[]): any[] {
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
}
export const getInitialValue = query =>
Value.fromJSON({
document: {
nodes: [
{
object: 'block',
type: 'paragraph',
nodes: [
{
object: 'text',
leaves: [
{
text: query,
},
],
},
],
},
],
},
export const makeFragment = (text: string): Document => {
const lines = text.split('\n').map(line =>
Block.create({
type: 'paragraph',
nodes: [Text.create(line)],
})
);
const fragment = Document.create({
nodes: lines,
});
return fragment;
};
class Portal extends React.Component<any, any> {
node: any;
export const getInitialValue = (value: string): Value => Value.create({ document: makeFragment(value) });
constructor(props) {
super(props);
const { index = 0, prefix = 'query' } = props;
this.node = document.createElement('div');
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
document.body.appendChild(this.node);
}
componentWillUnmount() {
document.body.removeChild(this.node);
}
render() {
return ReactDOM.createPortal(this.props.children, this.node);
}
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;
}
class QueryField extends React.Component<any, any> {
menuEl: any;
plugins: any;
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;
}
interface TypeaheadFieldProps {
additionalPlugins?: any[];
cleanText?: (text: string) => string;
initialValue: string | null;
onBlur?: () => void;
onFocus?: () => void;
onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
onValueChanged?: (value: Value) => void;
onWillApplySuggestion?: (suggestion: string, state: TypeaheadFieldState) => string;
placeholder?: string;
portalPrefix?: string;
}
export interface TypeaheadFieldState {
suggestions: SuggestionGroup[];
typeaheadContext: string | null;
typeaheadIndex: number;
typeaheadPrefix: string;
typeaheadText: string;
value: Value;
}
export interface TypeaheadInput {
editorNode: Element;
prefix: string;
selection?: Selection;
text: string;
wrapperNode: Element;
}
export interface TypeaheadOutput {
context?: string;
refresher?: Promise<{}>;
suggestions: SuggestionGroup[];
}
class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldState> {
menuEl: HTMLElement | null;
plugins: any[];
resetTimer: any;
constructor(props, context) {
super(props, context);
const { prismDefinition = {}, prismLanguage = 'promql' } = props;
this.plugins = [
BracesPlugin(),
ClearPlugin(),
RunnerPlugin({ handler: props.onPressEnter }),
NewlinePlugin(),
PluginPrism({ definition: prismDefinition, language: prismLanguage }),
];
// Base plugins
this.plugins = [BracesPlugin(), ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins];
this.state = {
labelKeys: {},
labelValues: {},
metrics: props.metrics || [],
suggestions: [],
typeaheadContext: null,
typeaheadIndex: 0,
typeaheadPrefix: '',
value: getInitialValue(props.initialQuery || ''),
typeaheadText: '',
value: getInitialValue(props.initialValue || ''),
};
}
componentDidMount() {
this.updateMenu();
if (this.props.metrics === undefined) {
this.fetchMetricNames();
}
}
componentWillUnmount() {
@ -112,12 +169,9 @@ class QueryField extends React.Component<any, any> {
}
componentWillReceiveProps(nextProps) {
if (nextProps.metrics && nextProps.metrics !== this.props.metrics) {
this.setState({ metrics: nextProps.metrics }, this.onMetricsReceived);
}
// initialQuery is null in case the user typed
if (nextProps.initialQuery !== null && nextProps.initialQuery !== this.props.initialQuery) {
this.setState({ value: getInitialValue(nextProps.initialQuery) });
// initialValue is null in case the user typed
if (nextProps.initialValue !== null && nextProps.initialValue !== this.props.initialValue) {
this.setState({ value: getInitialValue(nextProps.initialValue) });
}
}
@ -125,48 +179,28 @@ class QueryField extends React.Component<any, any> {
const changed = value.document !== this.state.value.document;
this.setState({ value }, () => {
if (changed) {
this.handleChangeQuery();
this.handleChangeValue();
}
});
window.requestAnimationFrame(this.handleTypeahead);
};
onMetricsReceived = () => {
if (!this.state.metrics) {
return;
if (changed) {
window.requestAnimationFrame(this.handleTypeahead);
}
setPrismTokens(this.props.prismLanguage, METRIC_MARK, this.state.metrics);
// Trigger re-render
window.requestAnimationFrame(() => {
// Bogus edit to trigger highlighting
const change = this.state.value
.change()
.insertText(' ')
.deleteBackward(1);
this.onChange(change);
});
};
request = url => {
if (this.props.request) {
return this.props.request(url);
}
return fetch(url);
};
handleChangeQuery = () => {
handleChangeValue = () => {
// Send text change to parent
const { onQueryChange } = this.props;
if (onQueryChange) {
onQueryChange(Plain.serialize(this.state.value));
const { onValueChanged } = this.props;
if (onValueChanged) {
onValueChanged(Plain.serialize(this.state.value));
}
};
handleTypeahead = debounce(() => {
handleTypeahead = _.debounce(async () => {
const selection = window.getSelection();
if (selection.anchorNode) {
const { cleanText, onTypeahead } = this.props;
if (onTypeahead && selection.anchorNode) {
const wrapperNode = selection.anchorNode.parentElement;
const editorNode = wrapperNode.closest('.slate-query-field');
if (!editorNode || this.state.value.isBlurred) {
@ -175,164 +209,96 @@ class QueryField extends React.Component<any, any> {
}
const range = selection.getRangeAt(0);
const text = selection.anchorNode.textContent;
const offset = range.startOffset;
const prefix = cleanText(text.substr(0, offset));
// Determine candidates by context
const suggestionGroups = [];
const wrapperClasses = wrapperNode.classList;
let typeaheadContext = null;
// Take first metric as lucky guess
const metricNode = editorNode.querySelector(`.${METRIC_MARK}`);
if (wrapperClasses.contains('context-range')) {
// Rate ranges
typeaheadContext = 'context-range';
suggestionGroups.push({
label: 'Range vector',
items: [...RATE_RANGES],
});
} else if (wrapperClasses.contains('context-labels') && metricNode) {
const metric = metricNode.textContent;
const labelKeys = this.state.labelKeys[metric];
if (labelKeys) {
if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
// Label values
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
if (labelKeyNode) {
const labelKey = labelKeyNode.textContent;
const labelValues = this.state.labelValues[metric][labelKey];
typeaheadContext = 'context-label-values';
suggestionGroups.push({
label: 'Label values',
items: labelValues,
});
}
} else {
// Label keys
typeaheadContext = 'context-labels';
suggestionGroups.push({ label: 'Labels', items: labelKeys });
}
} else {
this.fetchMetricLabels(metric);
}
} else if (wrapperClasses.contains('context-labels') && !metricNode) {
// Empty name 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('=')) || wrapperClasses.contains('attr-value')) {
// Label values
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
if (labelKeyNode) {
const labelKey = labelKeyNode.textContent;
if (this.state.labelValues[EMPTY_METRIC]) {
const labelValues = this.state.labelValues[EMPTY_METRIC][labelKey];
typeaheadContext = 'context-label-values';
suggestionGroups.push({
label: 'Label values',
items: labelValues,
});
} else {
// Can only query label values for now (API to query keys is under development)
this.fetchLabelValues(labelKey);
}
}
} else {
// Label keys
typeaheadContext = 'context-labels';
suggestionGroups.push({ label: 'Labels', items: labelKeys });
}
} else if (metricNode && wrapperClasses.contains('context-aggregation')) {
typeaheadContext = 'context-aggregation';
const metric = metricNode.textContent;
const labelKeys = this.state.labelKeys[metric];
if (labelKeys) {
suggestionGroups.push({ label: 'Labels', items: labelKeys });
} else {
this.fetchMetricLabels(metric);
}
} else if (
(this.state.metrics && ((prefix && !wrapperClasses.contains('token')) || text.match(/[+\-*/^%]/))) ||
wrapperClasses.contains('context-function')
) {
// Need prefix for metrics
typeaheadContext = 'context-metrics';
suggestionGroups.push({
label: 'Metrics',
items: this.state.metrics,
});
const text = selection.anchorNode.textContent;
let prefix = text.substr(0, offset);
if (cleanText) {
prefix = cleanText(prefix);
}
let results = 0;
const filteredSuggestions = suggestionGroups.map(group => {
if (group.items) {
group.items = group.items.filter(c => c.length !== prefix.length && c.indexOf(prefix) > -1);
results += group.items.length;
const { suggestions, context, refresher } = onTypeahead({
editorNode,
prefix,
selection,
text,
wrapperNode,
});
const filteredSuggestions = suggestions
.map(group => {
if (group.items) {
if (prefix) {
// Filter groups based on prefix
if (!group.skipFilter) {
group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length);
if (group.prefixMatch) {
group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) === 0);
} else {
group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) > -1);
}
}
// Filter out the already typed value (prefix) unless it inserts custom text
group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix);
}
group.items = _.sortBy(group.items, item => item.sortText || item.label);
}
return group;
})
.filter(group => group.items && group.items.length > 0); // Filter out empty groups
this.setState(
{
suggestions: filteredSuggestions,
typeaheadPrefix: prefix,
typeaheadContext: context,
typeaheadText: text,
},
() => {
if (refresher) {
refresher.then(this.handleTypeahead).catch(e => console.error(e));
}
}
return group;
});
console.log('handleTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);
this.setState({
typeaheadPrefix: prefix,
typeaheadContext,
typeaheadText: text,
suggestions: results > 0 ? filteredSuggestions : [],
});
);
}
}, TYPEAHEAD_DEBOUNCE);
applyTypeahead(change, suggestion) {
const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
applyTypeahead(change: Change, suggestion: Suggestion): Change {
const { cleanText, onWillApplySuggestion } = this.props;
const { typeaheadPrefix, typeaheadText } = this.state;
let suggestionText = suggestion.insertText || suggestion.label;
const move = suggestion.move || 0;
// Modify suggestion based on context
switch (typeaheadContext) {
case 'context-labels': {
const nextChar = getNextCharacter();
if (!nextChar || nextChar === '}' || nextChar === ',') {
suggestion += '=';
}
break;
}
case 'context-label-values': {
// Always add quotes and remove existing ones instead
if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
suggestion = `"${suggestion}`;
}
if (getNextCharacter() !== '"') {
suggestion = `${suggestion}"`;
}
break;
}
default:
if (onWillApplySuggestion) {
suggestionText = onWillApplySuggestion(suggestionText, { ...this.state });
}
this.resetTypeahead();
// Remove the current, incomplete text and replace it with the selected suggestion
let backward = typeaheadPrefix.length;
const text = cleanText(typeaheadText);
const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
const text = cleanText ? cleanText(typeaheadText) : typeaheadText;
const suffixLength = text.length - typeaheadPrefix.length;
const offset = typeaheadText.indexOf(typeaheadPrefix);
const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestion === typeaheadText);
const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
const forward = midWord ? suffixLength + offset : 0;
return (
change
// TODO this line breaks if cursor was moved left and length is longer than whole prefix
// If new-lines, apply suggestion as block
if (suggestionText.match(/\n/)) {
const fragment = makeFragment(suggestionText);
return change
.deleteBackward(backward)
.deleteForward(forward)
.insertText(suggestion)
.focus()
);
.insertFragment(fragment)
.focus();
}
return change
.deleteBackward(backward)
.deleteForward(forward)
.insertText(suggestionText)
.move(move)
.focus();
}
onKeyDown = (event, change) => {
@ -413,74 +379,6 @@ class QueryField extends React.Component<any, any> {
});
};
async fetchLabelValues(key) {
const url = `/api/v1/label/${key}/values`;
try {
const res = await this.request(url);
console.log(res);
const body = await (res.data || res.json());
const pairs = this.state.labelValues[EMPTY_METRIC];
const values = {
...pairs,
[key]: body.data,
};
// const labelKeys = {
// ...this.state.labelKeys,
// [EMPTY_METRIC]: keys,
// };
const labelValues = {
...this.state.labelValues,
[EMPTY_METRIC]: values,
};
this.setState({ labelValues }, this.handleTypeahead);
} catch (e) {
if (this.props.onRequestError) {
this.props.onRequestError(e);
} else {
console.error(e);
}
}
}
async fetchMetricLabels(name) {
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 labelKeys = {
...this.state.labelKeys,
[name]: keys,
};
const labelValues = {
...this.state.labelValues,
[name]: values,
};
this.setState({ labelKeys, labelValues }, this.handleTypeahead);
} catch (e) {
if (this.props.onRequestError) {
this.props.onRequestError(e);
} else {
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());
this.setState({ metrics: body.data }, this.onMetricsReceived);
} catch (error) {
if (this.props.onRequestError) {
this.props.onRequestError(error);
} else {
console.error(error);
}
}
}
handleBlur = () => {
const { onBlur } = this.props;
// If we dont wait here, menu clicks wont work because the menu
@ -498,7 +396,7 @@ class QueryField extends React.Component<any, any> {
}
};
handleClickMenu = item => {
onClickMenu = (item: Suggestion) => {
// Manually triggering change
const change = this.applyTypeahead(this.state.value.change(), item);
this.onChange(change);
@ -531,7 +429,7 @@ class QueryField extends React.Component<any, any> {
// Write DOM
requestAnimationFrame(() => {
menu.style.opacity = 1;
menu.style.opacity = '1';
menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
menu.style.left = `${rect.left + scrollX - 2}px`;
});
@ -554,17 +452,16 @@ class QueryField extends React.Component<any, any> {
let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
const flattenedSuggestions = flattenSuggestions(suggestions);
selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
const selectedKeys = (flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : []).map(
i => (typeof i === 'object' ? i.text : i)
);
const selectedItem: Suggestion | null =
flattenedSuggestions.length > 0 ? flattenedSuggestions[selectedIndex] : null;
// Create typeahead in DOM root so we can later position it absolutely
return (
<Portal prefix={portalPrefix}>
<Typeahead
menuRef={this.menuRef}
selectedItems={selectedKeys}
onClickItem={this.handleClickMenu}
selectedItem={selectedItem}
onClickItem={this.onClickMenu}
groupedItems={suggestions}
/>
</Portal>
@ -591,4 +488,24 @@ class QueryField extends React.Component<any, any> {
}
}
class Portal extends React.Component<{ index?: number; prefix: string }, {}> {
node: HTMLElement;
constructor(props) {
super(props);
const { index = 0, prefix = 'query' } = props;
this.node = document.createElement('div');
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
document.body.appendChild(this.node);
}
componentWillUnmount() {
document.body.removeChild(this.node);
}
render() {
return ReactDOM.createPortal(this.props.children, this.node);
}
}
export default QueryField;

View File

@ -1,7 +1,6 @@
import React, { PureComponent } from 'react';
import promql from './slate-plugins/prism/promql';
import QueryField from './QueryField';
import QueryField from './PromQueryField';
class QueryRow extends PureComponent<any, any> {
constructor(props) {
@ -62,9 +61,6 @@ class QueryRow extends PureComponent<any, any> {
portalPrefix="explore"
onPressEnter={this.handlePressEnter}
onQueryChange={this.handleChangeQuery}
placeholder="Enter a PromQL query"
prismLanguage="promql"
prismDefinition={promql}
request={request}
/>
</div>

View File

@ -1,17 +1,26 @@
import React from 'react';
function scrollIntoView(el) {
import { Suggestion, SuggestionGroup } from './QueryField';
function scrollIntoView(el: HTMLElement) {
if (!el || !el.offsetParent) {
return;
}
const container = el.offsetParent;
const container = el.offsetParent as HTMLElement;
if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) {
container.scrollTop = el.offsetTop - container.offsetTop;
}
}
class TypeaheadItem extends React.PureComponent<any, any> {
el: any;
interface TypeaheadItemProps {
isSelected: boolean;
item: Suggestion;
onClickItem: (Suggestion) => void;
}
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
el: HTMLElement;
componentDidUpdate(prevProps) {
if (this.props.isSelected && !prevProps.isSelected) {
scrollIntoView(this.el);
@ -22,20 +31,30 @@ class TypeaheadItem extends React.PureComponent<any, any> {
this.el = el;
};
onClick = () => {
this.props.onClickItem(this.props.item);
};
render() {
const { hint, isSelected, label, onClickItem } = this.props;
const { isSelected, item } = this.props;
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
const onClick = () => onClickItem(label);
return (
<li ref={this.getRef} className={className} onClick={onClick}>
{label}
{hint && isSelected ? <div className="typeahead-item-hint">{hint}</div> : null}
<li ref={this.getRef} className={className} onClick={this.onClick}>
{item.detail || item.label}
{item.documentation && isSelected ? <div className="typeahead-item-hint">{item.documentation}</div> : null}
</li>
);
}
}
class TypeaheadGroup extends React.PureComponent<any, any> {
interface TypeaheadGroupProps {
items: Suggestion[];
label: string;
onClickItem: (Suggestion) => void;
selected: Suggestion;
}
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
render() {
const { items, label, selected, onClickItem } = this.props;
return (
@ -43,16 +62,8 @@ class TypeaheadGroup extends React.PureComponent<any, any> {
<div className="typeahead-group__title">{label}</div>
<ul className="typeahead-group__list">
{items.map(item => {
const text = typeof item === 'object' ? item.text : item;
const label = typeof item === 'object' ? item.display || item.text : item;
return (
<TypeaheadItem
key={text}
onClickItem={onClickItem}
isSelected={selected.indexOf(text) > -1}
hint={item.hint}
label={label}
/>
<TypeaheadItem key={item.label} onClickItem={onClickItem} isSelected={selected === item} item={item} />
);
})}
</ul>
@ -61,13 +72,19 @@ class TypeaheadGroup extends React.PureComponent<any, any> {
}
}
class Typeahead extends React.PureComponent<any, any> {
interface TypeaheadProps {
groupedItems: SuggestionGroup[];
menuRef: any;
selectedItem: Suggestion | null;
onClickItem: (Suggestion) => void;
}
class Typeahead extends React.PureComponent<TypeaheadProps, {}> {
render() {
const { groupedItems, menuRef, selectedItems, onClickItem } = this.props;
const { groupedItems, menuRef, selectedItem, onClickItem } = this.props;
return (
<ul className="typeahead" ref={menuRef}>
{groupedItems.map(g => (
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItems} {...g} />
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItem} {...g} />
))}
</ul>
);

View File

@ -1,67 +1,368 @@
/* tslint:disable max-line-length */
export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without'];
const AGGREGATION_OPERATORS = [
'sum',
'min',
'max',
'avg',
'stddev',
'stdvar',
'count',
'count_values',
'bottomk',
'topk',
'quantile',
{
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,
'abs',
'absent',
'ceil',
'changes',
'clamp_max',
'clamp_min',
'count_scalar',
'day_of_month',
'day_of_week',
'days_in_month',
'delta',
'deriv',
'drop_common_labels',
'exp',
'floor',
'histogram_quantile',
'holt_winters',
'hour',
'idelta',
'increase',
'irate',
'label_replace',
'ln',
'log2',
'log10',
'minute',
'month',
'predict_linear',
'rate',
'resets',
'round',
'scalar',
'sort',
'sort_desc',
'sqrt',
'time',
'vector',
'year',
'avg_over_time',
'min_over_time',
'max_over_time',
'sum_over_time',
'count_over_time',
'quantile_over_time',
'stddev_over_time',
'stdvar_over_time',
{
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 = {
@ -93,7 +394,7 @@ const tokenizer = {
},
},
},
function: new RegExp(`\\b(?:${FUNCTIONS.join('|')})(?=\\s*\\()`, 'i'),
function: new RegExp(`\\b(?:${FUNCTIONS.map(f => f.label).join('|')})(?=\\s*\\()`, 'i'),
'context-range': [
{
pattern: /\[[^\]]*(?=])/, // [1m]

View File

@ -71,6 +71,7 @@
.typeahead-item-hint {
font-size: $font-size-xs;
color: $text-color;
white-space: normal;
}
}
}