mirror of https://github.com/grafana/grafana
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 readabilitypull/12733/head
parent
1db2e869c5
commit
7699451d94
@ -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' }]); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -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; |
||||||
Loading…
Reference in new issue