mirror of
https://github.com/grafana/grafana.git
synced 2025-01-08 15:13:30 -06:00
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:
parent
1db2e869c5
commit
7699451d94
125
public/app/containers/Explore/PromQueryField.jest.tsx
Normal file
125
public/app/containers/Explore/PromQueryField.jest.tsx
Normal 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' }]);
|
||||
});
|
||||
});
|
||||
});
|
340
public/app/containers/Explore/PromQueryField.tsx
Normal file
340
public/app/containers/Explore/PromQueryField.tsx
Normal 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;
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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]
|
||||
|
@ -71,6 +71,7 @@
|
||||
.typeahead-item-hint {
|
||||
font-size: $font-size-xs;
|
||||
color: $text-color;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user