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