mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge pull request #13824 from grafana/davkal/explore-plugins
Explore: move suggestions logic to datasource language provider
This commit is contained in:
commit
239dfbc9ae
@ -695,11 +695,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
request = url => {
|
|
||||||
const { datasource } = this.state;
|
|
||||||
return datasource.metadataRequest(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
cloneState(): ExploreState {
|
cloneState(): ExploreState {
|
||||||
// Copy state, but copy queries including modifications
|
// Copy state, but copy queries including modifications
|
||||||
return {
|
return {
|
||||||
@ -831,9 +826,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
|||||||
{datasource && !datasourceError ? (
|
{datasource && !datasourceError ? (
|
||||||
<div className="explore-container">
|
<div className="explore-container">
|
||||||
<QueryRows
|
<QueryRows
|
||||||
|
datasource={datasource}
|
||||||
history={history}
|
history={history}
|
||||||
queries={queries}
|
queries={queries}
|
||||||
request={this.request}
|
|
||||||
onAddQueryRow={this.onAddQueryRow}
|
onAddQueryRow={this.onAddQueryRow}
|
||||||
onChangeQuery={this.onChangeQuery}
|
onChangeQuery={this.onChangeQuery}
|
||||||
onClickHintFix={this.onModifyQueries}
|
onClickHintFix={this.onModifyQueries}
|
||||||
|
@ -1,231 +1,4 @@
|
|||||||
import React from 'react';
|
import { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
|
||||||
import Enzyme, { shallow } from 'enzyme';
|
|
||||||
import Adapter from 'enzyme-adapter-react-16';
|
|
||||||
import Plain from 'slate-plain-serializer';
|
|
||||||
|
|
||||||
import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
|
|
||||||
|
|
||||||
Enzyme.configure({ adapter: new Adapter() });
|
|
||||||
|
|
||||||
describe('PromQueryField typeahead handling', () => {
|
|
||||||
const defaultProps = {
|
|
||||||
request: () => ({ data: { data: [] } }),
|
|
||||||
};
|
|
||||||
|
|
||||||
it('returns default suggestions on emtpty context', () => {
|
|
||||||
const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
|
|
||||||
const result = instance.getTypeahead({ text: '', prefix: '', wrapperClasses: [] });
|
|
||||||
expect(result.context).toBeUndefined();
|
|
||||||
expect(result.refresher).toBeUndefined();
|
|
||||||
expect(result.suggestions.length).toEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('range suggestions', () => {
|
|
||||||
it('returns range suggestions in range context', () => {
|
|
||||||
const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
|
|
||||||
const result = instance.getTypeahead({ text: '1', prefix: '1', wrapperClasses: ['context-range'] });
|
|
||||||
expect(result.context).toBe('context-range');
|
|
||||||
expect(result.refresher).toBeUndefined();
|
|
||||||
expect(result.suggestions).toEqual([
|
|
||||||
{
|
|
||||||
items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }],
|
|
||||||
label: 'Range vector',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('metric suggestions', () => {
|
|
||||||
it('returns metrics suggestions by default', () => {
|
|
||||||
const instance = shallow(
|
|
||||||
<PromQueryField {...defaultProps} metrics={['foo', 'bar']} />
|
|
||||||
).instance() as PromQueryField;
|
|
||||||
const result = instance.getTypeahead({ text: 'a', prefix: 'a', wrapperClasses: [] });
|
|
||||||
expect(result.context).toBeUndefined();
|
|
||||||
expect(result.refresher).toBeUndefined();
|
|
||||||
expect(result.suggestions.length).toEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns default suggestions after a binary operator', () => {
|
|
||||||
const instance = shallow(
|
|
||||||
<PromQueryField {...defaultProps} metrics={['foo', 'bar']} />
|
|
||||||
).instance() as PromQueryField;
|
|
||||||
const result = instance.getTypeahead({ text: '*', prefix: '', wrapperClasses: [] });
|
|
||||||
expect(result.context).toBeUndefined();
|
|
||||||
expect(result.refresher).toBeUndefined();
|
|
||||||
expect(result.suggestions.length).toEqual(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('label suggestions', () => {
|
|
||||||
it('returns default label suggestions on label context and no metric', () => {
|
|
||||||
const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
|
|
||||||
const value = Plain.deserialize('{}');
|
|
||||||
const range = value.selection.merge({
|
|
||||||
anchorOffset: 1,
|
|
||||||
});
|
|
||||||
const valueWithSelection = value.change().select(range).value;
|
|
||||||
const result = instance.getTypeahead({
|
|
||||||
text: '',
|
|
||||||
prefix: '',
|
|
||||||
wrapperClasses: ['context-labels'],
|
|
||||||
value: valueWithSelection,
|
|
||||||
});
|
|
||||||
expect(result.context).toBe('context-labels');
|
|
||||||
expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns label suggestions on label context and metric', () => {
|
|
||||||
const instance = shallow(
|
|
||||||
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric"}': ['bar'] }} />
|
|
||||||
).instance() as PromQueryField;
|
|
||||||
const value = Plain.deserialize('metric{}');
|
|
||||||
const range = value.selection.merge({
|
|
||||||
anchorOffset: 7,
|
|
||||||
});
|
|
||||||
const valueWithSelection = value.change().select(range).value;
|
|
||||||
const result = instance.getTypeahead({
|
|
||||||
text: '',
|
|
||||||
prefix: '',
|
|
||||||
wrapperClasses: ['context-labels'],
|
|
||||||
value: valueWithSelection,
|
|
||||||
});
|
|
||||||
expect(result.context).toBe('context-labels');
|
|
||||||
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns label suggestions on label context but leaves out labels that already exist', () => {
|
|
||||||
const instance = shallow(
|
|
||||||
<PromQueryField
|
|
||||||
{...defaultProps}
|
|
||||||
labelKeys={{ '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] }}
|
|
||||||
/>
|
|
||||||
).instance() as PromQueryField;
|
|
||||||
const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}');
|
|
||||||
const range = value.selection.merge({
|
|
||||||
anchorOffset: 36,
|
|
||||||
});
|
|
||||||
const valueWithSelection = value.change().select(range).value;
|
|
||||||
const result = instance.getTypeahead({
|
|
||||||
text: '',
|
|
||||||
prefix: '',
|
|
||||||
wrapperClasses: ['context-labels'],
|
|
||||||
value: valueWithSelection,
|
|
||||||
});
|
|
||||||
expect(result.context).toBe('context-labels');
|
|
||||||
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns label value suggestions inside a label value context after a negated matching operator', () => {
|
|
||||||
const instance = shallow(
|
|
||||||
<PromQueryField
|
|
||||||
{...defaultProps}
|
|
||||||
labelKeys={{ '{}': ['label'] }}
|
|
||||||
labelValues={{ '{}': { label: ['a', 'b', 'c'] } }}
|
|
||||||
/>
|
|
||||||
).instance() as PromQueryField;
|
|
||||||
const value = Plain.deserialize('{label!=}');
|
|
||||||
const range = value.selection.merge({ anchorOffset: 8 });
|
|
||||||
const valueWithSelection = value.change().select(range).value;
|
|
||||||
const result = instance.getTypeahead({
|
|
||||||
text: '!=',
|
|
||||||
prefix: '',
|
|
||||||
wrapperClasses: ['context-labels'],
|
|
||||||
labelKey: 'label',
|
|
||||||
value: valueWithSelection,
|
|
||||||
});
|
|
||||||
expect(result.context).toBe('context-label-values');
|
|
||||||
expect(result.suggestions).toEqual([
|
|
||||||
{
|
|
||||||
items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }],
|
|
||||||
label: 'Label values for "label"',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a refresher on label context and unavailable metric', () => {
|
|
||||||
const instance = shallow(
|
|
||||||
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="foo"}': ['bar'] }} />
|
|
||||||
).instance() as PromQueryField;
|
|
||||||
const value = Plain.deserialize('metric{}');
|
|
||||||
const range = value.selection.merge({
|
|
||||||
anchorOffset: 7,
|
|
||||||
});
|
|
||||||
const valueWithSelection = value.change().select(range).value;
|
|
||||||
const result = instance.getTypeahead({
|
|
||||||
text: '',
|
|
||||||
prefix: '',
|
|
||||||
wrapperClasses: ['context-labels'],
|
|
||||||
value: valueWithSelection,
|
|
||||||
});
|
|
||||||
expect(result.context).toBeUndefined();
|
|
||||||
expect(result.refresher).toBeInstanceOf(Promise);
|
|
||||||
expect(result.suggestions).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns label values on label context when given a metric and a label key', () => {
|
|
||||||
const instance = shallow(
|
|
||||||
<PromQueryField
|
|
||||||
{...defaultProps}
|
|
||||||
labelKeys={{ '{__name__="metric"}': ['bar'] }}
|
|
||||||
labelValues={{ '{__name__="metric"}': { bar: ['baz'] } }}
|
|
||||||
/>
|
|
||||||
).instance() as PromQueryField;
|
|
||||||
const value = Plain.deserialize('metric{bar=ba}');
|
|
||||||
const range = value.selection.merge({
|
|
||||||
anchorOffset: 13,
|
|
||||||
});
|
|
||||||
const valueWithSelection = value.change().select(range).value;
|
|
||||||
const result = instance.getTypeahead({
|
|
||||||
text: '=ba',
|
|
||||||
prefix: 'ba',
|
|
||||||
wrapperClasses: ['context-labels'],
|
|
||||||
labelKey: 'bar',
|
|
||||||
value: valueWithSelection,
|
|
||||||
});
|
|
||||||
expect(result.context).toBe('context-label-values');
|
|
||||||
expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns label suggestions on aggregation context and metric w/ selector', () => {
|
|
||||||
const instance = shallow(
|
|
||||||
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric",foo="xx"}': ['bar'] }} />
|
|
||||||
).instance() as PromQueryField;
|
|
||||||
const value = Plain.deserialize('sum(metric{foo="xx"}) by ()');
|
|
||||||
const range = value.selection.merge({
|
|
||||||
anchorOffset: 26,
|
|
||||||
});
|
|
||||||
const valueWithSelection = value.change().select(range).value;
|
|
||||||
const result = instance.getTypeahead({
|
|
||||||
text: '',
|
|
||||||
prefix: '',
|
|
||||||
wrapperClasses: ['context-aggregation'],
|
|
||||||
value: valueWithSelection,
|
|
||||||
});
|
|
||||||
expect(result.context).toBe('context-aggregation');
|
|
||||||
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns label suggestions on aggregation context and metric w/o selector', () => {
|
|
||||||
const instance = shallow(
|
|
||||||
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric"}': ['bar'] }} />
|
|
||||||
).instance() as PromQueryField;
|
|
||||||
const value = Plain.deserialize('sum(metric) by ()');
|
|
||||||
const range = value.selection.merge({
|
|
||||||
anchorOffset: 16,
|
|
||||||
});
|
|
||||||
const valueWithSelection = value.change().select(range).value;
|
|
||||||
const result = instance.getTypeahead({
|
|
||||||
text: '',
|
|
||||||
prefix: '',
|
|
||||||
wrapperClasses: ['context-aggregation'],
|
|
||||||
value: valueWithSelection,
|
|
||||||
});
|
|
||||||
expect(result.context).toBe('context-aggregation');
|
|
||||||
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('groupMetricsByPrefix()', () => {
|
describe('groupMetricsByPrefix()', () => {
|
||||||
it('returns an empty group for no metrics', () => {
|
it('returns an empty group for no metrics', () => {
|
||||||
|
@ -1,67 +1,23 @@
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import moment from 'moment';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Value } from 'slate';
|
|
||||||
import Cascader from 'rc-cascader';
|
import Cascader from 'rc-cascader';
|
||||||
import PluginPrism from 'slate-prism';
|
import PluginPrism from 'slate-prism';
|
||||||
import Prism from 'prismjs';
|
import Prism from 'prismjs';
|
||||||
|
|
||||||
|
import { TypeaheadOutput } from 'app/types/explore';
|
||||||
|
|
||||||
// dom also includes Element polyfills
|
// dom also includes Element polyfills
|
||||||
import { getNextCharacter, getPreviousCousin } from './utils/dom';
|
import { getNextCharacter, getPreviousCousin } from './utils/dom';
|
||||||
import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql';
|
|
||||||
import BracesPlugin from './slate-plugins/braces';
|
import BracesPlugin from './slate-plugins/braces';
|
||||||
import RunnerPlugin from './slate-plugins/runner';
|
import RunnerPlugin from './slate-plugins/runner';
|
||||||
import { processLabels, RATE_RANGES, cleanText, parseSelector } from './utils/prometheus';
|
|
||||||
|
|
||||||
import TypeaheadField, {
|
import TypeaheadField, { TypeaheadInput, TypeaheadFieldState } from './QueryField';
|
||||||
Suggestion,
|
|
||||||
SuggestionGroup,
|
|
||||||
TypeaheadInput,
|
|
||||||
TypeaheadFieldState,
|
|
||||||
TypeaheadOutput,
|
|
||||||
} from './QueryField';
|
|
||||||
|
|
||||||
const DEFAULT_KEYS = ['job', 'instance'];
|
|
||||||
const EMPTY_SELECTOR = '{}';
|
|
||||||
const HISTOGRAM_GROUP = '__histograms__';
|
const HISTOGRAM_GROUP = '__histograms__';
|
||||||
const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms
|
|
||||||
const HISTORY_ITEM_COUNT = 5;
|
|
||||||
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
|
|
||||||
const METRIC_MARK = 'metric';
|
const METRIC_MARK = 'metric';
|
||||||
const PRISM_SYNTAX = 'promql';
|
const PRISM_SYNTAX = 'promql';
|
||||||
export const RECORDING_RULES_GROUP = '__recording_rules__';
|
export const RECORDING_RULES_GROUP = '__recording_rules__';
|
||||||
|
|
||||||
export const wrapLabel = (label: string) => ({ label });
|
|
||||||
export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
|
|
||||||
suggestion.move = -1;
|
|
||||||
return suggestion;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Syntax highlighting
|
|
||||||
Prism.languages[PRISM_SYNTAX] = PrismPromql;
|
|
||||||
function setPrismTokens(language, field, values, alias = 'variable') {
|
|
||||||
Prism.languages[language][field] = {
|
|
||||||
alias,
|
|
||||||
pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addHistoryMetadata(item: Suggestion, history: any[]): Suggestion {
|
|
||||||
const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
|
|
||||||
const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label);
|
|
||||||
const count = historyForItem.length;
|
|
||||||
const recent = historyForItem[0];
|
|
||||||
let hint = `Queried ${count} times in the last 24h.`;
|
|
||||||
if (recent) {
|
|
||||||
const lastQueried = moment(recent.ts).fromNow();
|
|
||||||
hint = `${hint} Last queried ${lastQueried}.`;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
documentation: hint,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
|
export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
|
||||||
// Filter out recording rules and insert as first option
|
// Filter out recording rules and insert as first option
|
||||||
const ruleRegex = /:\w+:/;
|
const ruleRegex = /:\w+:/;
|
||||||
@ -133,48 +89,36 @@ interface CascaderOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PromQueryFieldProps {
|
interface PromQueryFieldProps {
|
||||||
|
datasource: any;
|
||||||
error?: string;
|
error?: string;
|
||||||
hint?: any;
|
hint?: any;
|
||||||
histogramMetrics?: string[];
|
|
||||||
history?: any[];
|
history?: any[];
|
||||||
initialQuery?: string | null;
|
initialQuery?: string | null;
|
||||||
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
|
|
||||||
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
|
|
||||||
metrics?: string[];
|
|
||||||
metricsByPrefix?: CascaderOption[];
|
metricsByPrefix?: CascaderOption[];
|
||||||
onClickHintFix?: (action: any) => void;
|
onClickHintFix?: (action: any) => void;
|
||||||
onPressEnter?: () => void;
|
onPressEnter?: () => void;
|
||||||
onQueryChange?: (value: string, override?: boolean) => void;
|
onQueryChange?: (value: string, override?: boolean) => void;
|
||||||
portalOrigin?: string;
|
|
||||||
request?: (url: string) => any;
|
|
||||||
supportsLogs?: boolean; // To be removed after Logging gets its own query field
|
supportsLogs?: boolean; // To be removed after Logging gets its own query field
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PromQueryFieldState {
|
interface PromQueryFieldState {
|
||||||
histogramMetrics: string[];
|
|
||||||
labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...]
|
|
||||||
labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
|
|
||||||
logLabelOptions: any[];
|
logLabelOptions: any[];
|
||||||
metrics: string[];
|
|
||||||
metricsOptions: any[];
|
metricsOptions: any[];
|
||||||
metricsByPrefix: CascaderOption[];
|
metricsByPrefix: CascaderOption[];
|
||||||
syntaxLoaded: boolean;
|
syntaxLoaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PromTypeaheadInput {
|
|
||||||
text: string;
|
|
||||||
prefix: string;
|
|
||||||
wrapperClasses: string[];
|
|
||||||
labelKey?: string;
|
|
||||||
value?: Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> {
|
class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> {
|
||||||
plugins: any[];
|
plugins: any[];
|
||||||
|
languageProvider: any;
|
||||||
|
|
||||||
constructor(props: PromQueryFieldProps, context) {
|
constructor(props: PromQueryFieldProps, context) {
|
||||||
super(props, context);
|
super(props, context);
|
||||||
|
|
||||||
|
if (props.datasource.languageProvider) {
|
||||||
|
this.languageProvider = props.datasource.languageProvider;
|
||||||
|
}
|
||||||
|
|
||||||
this.plugins = [
|
this.plugins = [
|
||||||
BracesPlugin(),
|
BracesPlugin(),
|
||||||
RunnerPlugin({ handler: props.onPressEnter }),
|
RunnerPlugin({ handler: props.onPressEnter }),
|
||||||
@ -185,26 +129,16 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
|||||||
];
|
];
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
histogramMetrics: props.histogramMetrics || [],
|
|
||||||
labelKeys: props.labelKeys || {},
|
|
||||||
labelValues: props.labelValues || {},
|
|
||||||
logLabelOptions: [],
|
logLabelOptions: [],
|
||||||
metrics: props.metrics || [],
|
metricsByPrefix: [],
|
||||||
metricsByPrefix: props.metricsByPrefix || [],
|
|
||||||
metricsOptions: [],
|
metricsOptions: [],
|
||||||
syntaxLoaded: false,
|
syntaxLoaded: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
// Temporarily reused by logging
|
if (this.languageProvider) {
|
||||||
const { supportsLogs } = this.props;
|
this.languageProvider.start().then(() => this.onReceiveMetrics());
|
||||||
if (supportsLogs) {
|
|
||||||
this.fetchLogLabels();
|
|
||||||
} else {
|
|
||||||
// Usual actions
|
|
||||||
this.fetchMetricNames();
|
|
||||||
this.fetchHistogramMetrics();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,15 +196,19 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
|||||||
};
|
};
|
||||||
|
|
||||||
onReceiveMetrics = () => {
|
onReceiveMetrics = () => {
|
||||||
const { histogramMetrics, metrics, metricsByPrefix } = this.state;
|
const { histogramMetrics, metrics } = this.languageProvider;
|
||||||
if (!metrics) {
|
if (!metrics) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update global prism config
|
Prism.languages[PRISM_SYNTAX] = this.languageProvider.getSyntax();
|
||||||
setPrismTokens(PRISM_SYNTAX, METRIC_MARK, metrics);
|
Prism.languages[PRISM_SYNTAX][METRIC_MARK] = {
|
||||||
|
alias: 'variable',
|
||||||
|
pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`),
|
||||||
|
};
|
||||||
|
|
||||||
// Build metrics tree
|
// Build metrics tree
|
||||||
|
const metricsByPrefix = groupMetricsByPrefix(metrics);
|
||||||
const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
|
const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
|
||||||
const metricsOptions = [
|
const metricsOptions = [
|
||||||
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
|
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
|
||||||
@ -281,6 +219,11 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
|||||||
};
|
};
|
||||||
|
|
||||||
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
|
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
|
||||||
|
if (!this.languageProvider) {
|
||||||
|
return { suggestions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { history } = this.props;
|
||||||
const { prefix, text, value, wrapperNode } = typeahead;
|
const { prefix, text, value, wrapperNode } = typeahead;
|
||||||
|
|
||||||
// Get DOM-dependent context
|
// Get DOM-dependent context
|
||||||
@ -289,279 +232,20 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
|||||||
const labelKey = labelKeyNode && labelKeyNode.textContent;
|
const labelKey = labelKeyNode && labelKeyNode.textContent;
|
||||||
const nextChar = getNextCharacter();
|
const nextChar = getNextCharacter();
|
||||||
|
|
||||||
const result = this.getTypeahead({ text, value, prefix, wrapperClasses, labelKey });
|
const result = this.languageProvider.provideCompletionItems(
|
||||||
|
{ text, value, prefix, wrapperClasses, labelKey },
|
||||||
|
{ history }
|
||||||
|
);
|
||||||
|
|
||||||
console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
|
console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep this DOM-free for testing
|
|
||||||
getTypeahead({ prefix, wrapperClasses, text }: PromTypeaheadInput): TypeaheadOutput {
|
|
||||||
// Syntax spans have 3 classes by default. More indicate a recognized token
|
|
||||||
const tokenRecognized = wrapperClasses.length > 3;
|
|
||||||
// Determine candidates by CSS context
|
|
||||||
if (_.includes(wrapperClasses, 'context-range')) {
|
|
||||||
// Suggestions for metric[|]
|
|
||||||
return this.getRangeTypeahead();
|
|
||||||
} else if (_.includes(wrapperClasses, 'context-labels')) {
|
|
||||||
// Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
|
|
||||||
return this.getLabelTypeahead.apply(this, arguments);
|
|
||||||
} else if (_.includes(wrapperClasses, 'context-aggregation')) {
|
|
||||||
return this.getAggregationTypeahead.apply(this, arguments);
|
|
||||||
} else if (
|
|
||||||
// Show default suggestions in a couple of scenarios
|
|
||||||
(prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
|
|
||||||
(prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
|
|
||||||
text.match(/[+\-*/^%]/) // Anything after binary operator
|
|
||||||
) {
|
|
||||||
return this.getEmptyTypeahead();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
suggestions: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getEmptyTypeahead(): TypeaheadOutput {
|
|
||||||
const { history } = this.props;
|
|
||||||
const { metrics } = this.state;
|
|
||||||
const suggestions: SuggestionGroup[] = [];
|
|
||||||
|
|
||||||
if (history && history.length > 0) {
|
|
||||||
const historyItems = _.chain(history)
|
|
||||||
.uniqBy('query')
|
|
||||||
.take(HISTORY_ITEM_COUNT)
|
|
||||||
.map(h => h.query)
|
|
||||||
.map(wrapLabel)
|
|
||||||
.map(item => addHistoryMetadata(item, history))
|
|
||||||
.value();
|
|
||||||
|
|
||||||
suggestions.push({
|
|
||||||
prefixMatch: true,
|
|
||||||
skipSort: true,
|
|
||||||
label: 'History',
|
|
||||||
items: historyItems,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
suggestions.push({
|
|
||||||
prefixMatch: true,
|
|
||||||
label: 'Functions',
|
|
||||||
items: FUNCTIONS.map(setFunctionMove),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (metrics) {
|
|
||||||
suggestions.push({
|
|
||||||
label: 'Metrics',
|
|
||||||
items: metrics.map(wrapLabel),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { suggestions };
|
|
||||||
}
|
|
||||||
|
|
||||||
getRangeTypeahead(): TypeaheadOutput {
|
|
||||||
return {
|
|
||||||
context: 'context-range',
|
|
||||||
suggestions: [
|
|
||||||
{
|
|
||||||
label: 'Range vector',
|
|
||||||
items: [...RATE_RANGES].map(wrapLabel),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getAggregationTypeahead({ value }: PromTypeaheadInput): TypeaheadOutput {
|
|
||||||
let refresher: Promise<any> = null;
|
|
||||||
const suggestions: SuggestionGroup[] = [];
|
|
||||||
|
|
||||||
// sum(foo{bar="1"}) by (|)
|
|
||||||
const line = value.anchorBlock.getText();
|
|
||||||
const cursorOffset: number = value.anchorOffset;
|
|
||||||
// sum(foo{bar="1"}) by (
|
|
||||||
const leftSide = line.slice(0, cursorOffset);
|
|
||||||
const openParensAggregationIndex = leftSide.lastIndexOf('(');
|
|
||||||
const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('(');
|
|
||||||
const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
|
|
||||||
// foo{bar="1"}
|
|
||||||
const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
|
|
||||||
const selector = parseSelector(selectorString, selectorString.length - 2).selector;
|
|
||||||
|
|
||||||
const labelKeys = this.state.labelKeys[selector];
|
|
||||||
if (labelKeys) {
|
|
||||||
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
|
|
||||||
} else {
|
|
||||||
refresher = this.fetchSeriesLabels(selector);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
refresher,
|
|
||||||
suggestions,
|
|
||||||
context: 'context-aggregation',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getLabelTypeahead({ text, wrapperClasses, labelKey, value }: PromTypeaheadInput): TypeaheadOutput {
|
|
||||||
let context: string;
|
|
||||||
let refresher: Promise<any> = null;
|
|
||||||
const suggestions: SuggestionGroup[] = [];
|
|
||||||
const line = value.anchorBlock.getText();
|
|
||||||
const cursorOffset: number = value.anchorOffset;
|
|
||||||
|
|
||||||
// Get normalized selector
|
|
||||||
let selector;
|
|
||||||
let parsedSelector;
|
|
||||||
try {
|
|
||||||
parsedSelector = parseSelector(line, cursorOffset);
|
|
||||||
selector = parsedSelector.selector;
|
|
||||||
} catch {
|
|
||||||
selector = EMPTY_SELECTOR;
|
|
||||||
}
|
|
||||||
const containsMetric = selector.indexOf('__name__=') > -1;
|
|
||||||
const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
|
|
||||||
|
|
||||||
if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
|
|
||||||
// Label values
|
|
||||||
if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) {
|
|
||||||
const labelValues = this.state.labelValues[selector][labelKey];
|
|
||||||
context = 'context-label-values';
|
|
||||||
suggestions.push({
|
|
||||||
label: `Label values for "${labelKey}"`,
|
|
||||||
items: labelValues.map(wrapLabel),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Label keys
|
|
||||||
const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
|
|
||||||
if (labelKeys) {
|
|
||||||
const possibleKeys = _.difference(labelKeys, existingKeys);
|
|
||||||
if (possibleKeys.length > 0) {
|
|
||||||
context = 'context-labels';
|
|
||||||
suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query labels for selector
|
|
||||||
// Temporarily add skip for logging
|
|
||||||
if (selector && !this.state.labelValues[selector] && !this.props.supportsLogs) {
|
|
||||||
if (selector === EMPTY_SELECTOR) {
|
|
||||||
// Query label values for default labels
|
|
||||||
refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
|
|
||||||
} else {
|
|
||||||
refresher = this.fetchSeriesLabels(selector, !containsMetric);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { context, refresher, suggestions };
|
|
||||||
}
|
|
||||||
|
|
||||||
request = url => {
|
|
||||||
if (this.props.request) {
|
|
||||||
return this.props.request(url);
|
|
||||||
}
|
|
||||||
return fetch(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchHistogramMetrics() {
|
|
||||||
this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true, () => {
|
|
||||||
const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR];
|
|
||||||
if (histogramSeries && histogramSeries['__name__']) {
|
|
||||||
const histogramMetrics = histogramSeries['__name__'].slice().sort();
|
|
||||||
this.setState({ histogramMetrics }, this.onReceiveMetrics);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temporarily here while reusing this field for logging
|
|
||||||
async fetchLogLabels() {
|
|
||||||
const url = '/api/prom/label';
|
|
||||||
try {
|
|
||||||
const res = await this.request(url);
|
|
||||||
const body = await (res.data || res.json());
|
|
||||||
const labelKeys = body.data.slice().sort();
|
|
||||||
const labelKeysBySelector = {
|
|
||||||
...this.state.labelKeys,
|
|
||||||
[EMPTY_SELECTOR]: labelKeys,
|
|
||||||
};
|
|
||||||
const labelValuesByKey = {};
|
|
||||||
const logLabelOptions = [];
|
|
||||||
for (const key of labelKeys) {
|
|
||||||
const valuesUrl = `/api/prom/label/${key}/values`;
|
|
||||||
const res = await this.request(valuesUrl);
|
|
||||||
const body = await (res.data || res.json());
|
|
||||||
const values = body.data.slice().sort();
|
|
||||||
labelValuesByKey[key] = values;
|
|
||||||
logLabelOptions.push({
|
|
||||||
label: key,
|
|
||||||
value: key,
|
|
||||||
children: values.map(value => ({ label: value, value })),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const labelValues = { [EMPTY_SELECTOR]: labelValuesByKey };
|
|
||||||
this.setState({ labelKeys: labelKeysBySelector, labelValues, logLabelOptions });
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchLabelValues(key: string) {
|
|
||||||
const url = `/api/v1/label/${key}/values`;
|
|
||||||
try {
|
|
||||||
const res = await this.request(url);
|
|
||||||
const body = await (res.data || res.json());
|
|
||||||
const exisingValues = this.state.labelValues[EMPTY_SELECTOR];
|
|
||||||
const values = {
|
|
||||||
...exisingValues,
|
|
||||||
[key]: body.data,
|
|
||||||
};
|
|
||||||
const labelValues = {
|
|
||||||
...this.state.labelValues,
|
|
||||||
[EMPTY_SELECTOR]: values,
|
|
||||||
};
|
|
||||||
this.setState({ labelValues });
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchSeriesLabels(name: string, withName?: boolean, callback?: () => void) {
|
|
||||||
const url = `/api/v1/series?match[]=${name}`;
|
|
||||||
try {
|
|
||||||
const res = await this.request(url);
|
|
||||||
const body = await (res.data || res.json());
|
|
||||||
const { keys, values } = processLabels(body.data, withName);
|
|
||||||
const labelKeys = {
|
|
||||||
...this.state.labelKeys,
|
|
||||||
[name]: keys,
|
|
||||||
};
|
|
||||||
const labelValues = {
|
|
||||||
...this.state.labelValues,
|
|
||||||
[name]: values,
|
|
||||||
};
|
|
||||||
this.setState({ labelKeys, labelValues }, callback);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchMetricNames() {
|
|
||||||
const url = '/api/v1/label/__name__/values';
|
|
||||||
try {
|
|
||||||
const res = await this.request(url);
|
|
||||||
const body = await (res.data || res.json());
|
|
||||||
const metrics = body.data;
|
|
||||||
const metricsByPrefix = groupMetricsByPrefix(metrics);
|
|
||||||
this.setState({ metrics, metricsByPrefix }, this.onReceiveMetrics);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { error, hint, initialQuery, supportsLogs } = this.props;
|
const { error, hint, initialQuery, supportsLogs } = this.props;
|
||||||
const { logLabelOptions, metricsOptions, syntaxLoaded } = this.state;
|
const { logLabelOptions, metricsOptions, syntaxLoaded } = this.state;
|
||||||
|
const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="prom-query-field">
|
<div className="prom-query-field">
|
||||||
|
@ -5,6 +5,8 @@ import { Change, Value } from 'slate';
|
|||||||
import { Editor } from 'slate-react';
|
import { Editor } from 'slate-react';
|
||||||
import Plain from 'slate-plain-serializer';
|
import Plain from 'slate-plain-serializer';
|
||||||
|
|
||||||
|
import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
|
||||||
|
|
||||||
import ClearPlugin from './slate-plugins/clear';
|
import ClearPlugin from './slate-plugins/clear';
|
||||||
import NewlinePlugin from './slate-plugins/newline';
|
import NewlinePlugin from './slate-plugins/newline';
|
||||||
|
|
||||||
@ -13,87 +15,17 @@ import { makeFragment, makeValue } from './Value';
|
|||||||
|
|
||||||
export const TYPEAHEAD_DEBOUNCE = 100;
|
export const TYPEAHEAD_DEBOUNCE = 100;
|
||||||
|
|
||||||
function getSuggestionByIndex(suggestions: SuggestionGroup[], index: number): Suggestion {
|
function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem {
|
||||||
// Flatten suggestion groups
|
// Flatten suggestion groups
|
||||||
const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []);
|
const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []);
|
||||||
const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length;
|
const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length;
|
||||||
return flattenedSuggestions[correctedIndex];
|
return flattenedSuggestions[correctedIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasSuggestions(suggestions: SuggestionGroup[]): boolean {
|
function hasSuggestions(suggestions: CompletionItemGroup[]): boolean {
|
||||||
return suggestions && suggestions.length > 0;
|
return suggestions && suggestions.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Suggestion {
|
|
||||||
/**
|
|
||||||
* The label of this completion item. By default
|
|
||||||
* this is also the text that is inserted when selecting
|
|
||||||
* this completion.
|
|
||||||
*/
|
|
||||||
label: string;
|
|
||||||
/**
|
|
||||||
* The kind of this completion item. Based on the kind
|
|
||||||
* an icon is chosen by the editor.
|
|
||||||
*/
|
|
||||||
kind?: string;
|
|
||||||
/**
|
|
||||||
* A human-readable string with additional information
|
|
||||||
* about this item, like type or symbol information.
|
|
||||||
*/
|
|
||||||
detail?: string;
|
|
||||||
/**
|
|
||||||
* A human-readable string, can be Markdown, that represents a doc-comment.
|
|
||||||
*/
|
|
||||||
documentation?: string;
|
|
||||||
/**
|
|
||||||
* A string that should be used when comparing this item
|
|
||||||
* with other items. When `falsy` the `label` is used.
|
|
||||||
*/
|
|
||||||
sortText?: string;
|
|
||||||
/**
|
|
||||||
* A string that should be used when filtering a set of
|
|
||||||
* completion items. When `falsy` the `label` is used.
|
|
||||||
*/
|
|
||||||
filterText?: string;
|
|
||||||
/**
|
|
||||||
* A string or snippet that should be inserted in a document when selecting
|
|
||||||
* this completion. When `falsy` the `label` is used.
|
|
||||||
*/
|
|
||||||
insertText?: string;
|
|
||||||
/**
|
|
||||||
* Delete number of characters before the caret position,
|
|
||||||
* by default the letters from the beginning of the word.
|
|
||||||
*/
|
|
||||||
deleteBackwards?: number;
|
|
||||||
/**
|
|
||||||
* Number of steps to move after the insertion, can be negative.
|
|
||||||
*/
|
|
||||||
move?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuggestionGroup {
|
|
||||||
/**
|
|
||||||
* Label that will be displayed for all entries of this group.
|
|
||||||
*/
|
|
||||||
label: string;
|
|
||||||
/**
|
|
||||||
* List of suggestions of this group.
|
|
||||||
*/
|
|
||||||
items: Suggestion[];
|
|
||||||
/**
|
|
||||||
* If true, match only by prefix (and not mid-word).
|
|
||||||
*/
|
|
||||||
prefixMatch?: boolean;
|
|
||||||
/**
|
|
||||||
* If true, do not filter items in this group based on the search.
|
|
||||||
*/
|
|
||||||
skipFilter?: boolean;
|
|
||||||
/**
|
|
||||||
* If true, do not sort items.
|
|
||||||
*/
|
|
||||||
skipSort?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TypeaheadFieldProps {
|
interface TypeaheadFieldProps {
|
||||||
additionalPlugins?: any[];
|
additionalPlugins?: any[];
|
||||||
cleanText?: (text: string) => string;
|
cleanText?: (text: string) => string;
|
||||||
@ -110,7 +42,7 @@ interface TypeaheadFieldProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TypeaheadFieldState {
|
export interface TypeaheadFieldState {
|
||||||
suggestions: SuggestionGroup[];
|
suggestions: CompletionItemGroup[];
|
||||||
typeaheadContext: string | null;
|
typeaheadContext: string | null;
|
||||||
typeaheadIndex: number;
|
typeaheadIndex: number;
|
||||||
typeaheadPrefix: string;
|
typeaheadPrefix: string;
|
||||||
@ -127,12 +59,6 @@ export interface TypeaheadInput {
|
|||||||
wrapperNode: Element;
|
wrapperNode: Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TypeaheadOutput {
|
|
||||||
context?: string;
|
|
||||||
refresher?: Promise<{}>;
|
|
||||||
suggestions: SuggestionGroup[];
|
|
||||||
}
|
|
||||||
|
|
||||||
class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadFieldState> {
|
class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadFieldState> {
|
||||||
menuEl: HTMLElement | null;
|
menuEl: HTMLElement | null;
|
||||||
plugins: any[];
|
plugins: any[];
|
||||||
@ -293,7 +219,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
|
|||||||
}
|
}
|
||||||
}, TYPEAHEAD_DEBOUNCE);
|
}, TYPEAHEAD_DEBOUNCE);
|
||||||
|
|
||||||
applyTypeahead(change: Change, suggestion: Suggestion): Change {
|
applyTypeahead(change: Change, suggestion: CompletionItem): Change {
|
||||||
const { cleanText, onWillApplySuggestion, syntax } = this.props;
|
const { cleanText, onWillApplySuggestion, syntax } = this.props;
|
||||||
const { typeaheadPrefix, typeaheadText } = this.state;
|
const { typeaheadPrefix, typeaheadText } = this.state;
|
||||||
let suggestionText = suggestion.insertText || suggestion.label;
|
let suggestionText = suggestion.insertText || suggestion.label;
|
||||||
@ -422,7 +348,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onClickMenu = (item: Suggestion) => {
|
onClickMenu = (item: CompletionItem) => {
|
||||||
// Manually triggering change
|
// Manually triggering change
|
||||||
const change = this.applyTypeahead(this.state.value.change(), item);
|
const change = this.applyTypeahead(this.state.value.change(), item);
|
||||||
this.onChange(change);
|
this.onChange(change);
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
|
|
||||||
import { QueryTransaction } from 'app/types/explore';
|
import { QueryTransaction, HistoryItem, Query, QueryHint } from 'app/types/explore';
|
||||||
|
|
||||||
// TODO make this datasource-plugin-dependent
|
// TODO make this datasource-plugin-dependent
|
||||||
import QueryField from './PromQueryField';
|
import QueryField from './PromQueryField';
|
||||||
import QueryTransactions from './QueryTransactions';
|
import QueryTransactions from './QueryTransactions';
|
||||||
|
|
||||||
function getFirstHintFromTransactions(transactions: QueryTransaction[]) {
|
function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
|
||||||
const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
|
const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
|
||||||
if (transaction) {
|
if (transaction) {
|
||||||
return transaction.hints[0];
|
return transaction.hints[0];
|
||||||
@ -14,7 +14,30 @@ function getFirstHintFromTransactions(transactions: QueryTransaction[]) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
class QueryRow extends PureComponent<any, {}> {
|
interface QueryRowEventHandlers {
|
||||||
|
onAddQueryRow: (index: number) => void;
|
||||||
|
onChangeQuery: (value: string, index: number, override?: boolean) => void;
|
||||||
|
onClickHintFix: (action: object, index?: number) => void;
|
||||||
|
onExecuteQuery: () => void;
|
||||||
|
onRemoveQueryRow: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueryRowCommonProps {
|
||||||
|
className?: string;
|
||||||
|
datasource: any;
|
||||||
|
history: HistoryItem[];
|
||||||
|
// Temporarily
|
||||||
|
supportsLogs?: boolean;
|
||||||
|
transactions: QueryTransaction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryRowProps = QueryRowCommonProps &
|
||||||
|
QueryRowEventHandlers & {
|
||||||
|
index: number;
|
||||||
|
query: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class QueryRow extends PureComponent<QueryRowProps> {
|
||||||
onChangeQuery = (value, override?: boolean) => {
|
onChangeQuery = (value, override?: boolean) => {
|
||||||
const { index, onChangeQuery } = this.props;
|
const { index, onChangeQuery } = this.props;
|
||||||
if (onChangeQuery) {
|
if (onChangeQuery) {
|
||||||
@ -55,8 +78,8 @@ class QueryRow extends PureComponent<any, {}> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { history, query, request, supportsLogs, transactions } = this.props;
|
const { datasource, history, query, supportsLogs, transactions } = this.props;
|
||||||
const transactionWithError = transactions.find(t => t.error);
|
const transactionWithError = transactions.find(t => t.error !== undefined);
|
||||||
const hint = getFirstHintFromTransactions(transactions);
|
const hint = getFirstHintFromTransactions(transactions);
|
||||||
const queryError = transactionWithError ? transactionWithError.error : null;
|
const queryError = transactionWithError ? transactionWithError.error : null;
|
||||||
return (
|
return (
|
||||||
@ -66,6 +89,7 @@ class QueryRow extends PureComponent<any, {}> {
|
|||||||
</div>
|
</div>
|
||||||
<div className="query-row-field">
|
<div className="query-row-field">
|
||||||
<QueryField
|
<QueryField
|
||||||
|
datasource={datasource}
|
||||||
error={queryError}
|
error={queryError}
|
||||||
hint={hint}
|
hint={hint}
|
||||||
initialQuery={query}
|
initialQuery={query}
|
||||||
@ -73,7 +97,6 @@ class QueryRow extends PureComponent<any, {}> {
|
|||||||
onClickHintFix={this.onClickHintFix}
|
onClickHintFix={this.onClickHintFix}
|
||||||
onPressEnter={this.onPressEnter}
|
onPressEnter={this.onPressEnter}
|
||||||
onQueryChange={this.onChangeQuery}
|
onQueryChange={this.onChangeQuery}
|
||||||
request={request}
|
|
||||||
supportsLogs={supportsLogs}
|
supportsLogs={supportsLogs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -93,9 +116,14 @@ class QueryRow extends PureComponent<any, {}> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class QueryRows extends PureComponent<any, {}> {
|
type QueryRowsProps = QueryRowCommonProps &
|
||||||
|
QueryRowEventHandlers & {
|
||||||
|
queries: Query[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class QueryRows extends PureComponent<QueryRowsProps> {
|
||||||
render() {
|
render() {
|
||||||
const { className = '', queries, queryHints, transactions, ...handlers } = this.props;
|
const { className = '', queries, transactions, ...handlers } = this.props;
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{queries.map((q, index) => (
|
{queries.map((q, index) => (
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Highlighter from 'react-highlight-words';
|
import Highlighter from 'react-highlight-words';
|
||||||
|
|
||||||
import { Suggestion, SuggestionGroup } from './QueryField';
|
import { CompletionItem, CompletionItemGroup } from 'app/types/explore';
|
||||||
|
|
||||||
function scrollIntoView(el: HTMLElement) {
|
function scrollIntoView(el: HTMLElement) {
|
||||||
if (!el || !el.offsetParent) {
|
if (!el || !el.offsetParent) {
|
||||||
@ -15,12 +15,12 @@ function scrollIntoView(el: HTMLElement) {
|
|||||||
|
|
||||||
interface TypeaheadItemProps {
|
interface TypeaheadItemProps {
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
item: Suggestion;
|
item: CompletionItem;
|
||||||
onClickItem: (Suggestion) => void;
|
onClickItem: (Suggestion) => void;
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
|
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps> {
|
||||||
el: HTMLElement;
|
el: HTMLElement;
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
@ -53,14 +53,14 @@ class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TypeaheadGroupProps {
|
interface TypeaheadGroupProps {
|
||||||
items: Suggestion[];
|
items: CompletionItem[];
|
||||||
label: string;
|
label: string;
|
||||||
onClickItem: (Suggestion) => void;
|
onClickItem: (CompletionItem) => void;
|
||||||
selected: Suggestion;
|
selected: CompletionItem;
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
|
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps> {
|
||||||
render() {
|
render() {
|
||||||
const { items, label, selected, onClickItem, prefix } = this.props;
|
const { items, label, selected, onClickItem, prefix } = this.props;
|
||||||
return (
|
return (
|
||||||
@ -85,13 +85,13 @@ class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TypeaheadProps {
|
interface TypeaheadProps {
|
||||||
groupedItems: SuggestionGroup[];
|
groupedItems: CompletionItemGroup[];
|
||||||
menuRef: any;
|
menuRef: any;
|
||||||
selectedItem: Suggestion | null;
|
selectedItem: CompletionItem | null;
|
||||||
onClickItem: (Suggestion) => void;
|
onClickItem: (Suggestion) => void;
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
}
|
}
|
||||||
class Typeahead extends React.PureComponent<TypeaheadProps, {}> {
|
class Typeahead extends React.PureComponent<TypeaheadProps> {
|
||||||
render() {
|
render() {
|
||||||
const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props;
|
const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props;
|
||||||
return (
|
return (
|
||||||
|
@ -5,6 +5,7 @@ import kbn from 'app/core/utils/kbn';
|
|||||||
import * as dateMath from 'app/core/utils/datemath';
|
import * as dateMath from 'app/core/utils/datemath';
|
||||||
import PrometheusMetricFindQuery from './metric_find_query';
|
import PrometheusMetricFindQuery from './metric_find_query';
|
||||||
import { ResultTransformer } from './result_transformer';
|
import { ResultTransformer } from './result_transformer';
|
||||||
|
import PrometheusLanguageProvider from './language_provider';
|
||||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||||
|
|
||||||
import addLabelToQuery from './add_label_to_query';
|
import addLabelToQuery from './add_label_to_query';
|
||||||
@ -60,6 +61,7 @@ export class PrometheusDatasource {
|
|||||||
interval: string;
|
interval: string;
|
||||||
queryTimeout: string;
|
queryTimeout: string;
|
||||||
httpMethod: string;
|
httpMethod: string;
|
||||||
|
languageProvider: PrometheusLanguageProvider;
|
||||||
resultTransformer: ResultTransformer;
|
resultTransformer: ResultTransformer;
|
||||||
|
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
@ -76,6 +78,7 @@ export class PrometheusDatasource {
|
|||||||
this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
|
this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
|
||||||
this.resultTransformer = new ResultTransformer(templateSrv);
|
this.resultTransformer = new ResultTransformer(templateSrv);
|
||||||
this.ruleMappings = {};
|
this.ruleMappings = {};
|
||||||
|
this.languageProvider = new PrometheusLanguageProvider(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
334
public/app/plugins/datasource/prometheus/language_provider.ts
Normal file
334
public/app/plugins/datasource/prometheus/language_provider.ts
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CompletionItem,
|
||||||
|
CompletionItemGroup,
|
||||||
|
LanguageProvider,
|
||||||
|
TypeaheadInput,
|
||||||
|
TypeaheadOutput,
|
||||||
|
} from 'app/types/explore';
|
||||||
|
|
||||||
|
import { parseSelector, processLabels, RATE_RANGES } from './language_utils';
|
||||||
|
import PromqlSyntax, { FUNCTIONS } from './promql';
|
||||||
|
|
||||||
|
const DEFAULT_KEYS = ['job', 'instance'];
|
||||||
|
const EMPTY_SELECTOR = '{}';
|
||||||
|
const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms
|
||||||
|
const HISTORY_ITEM_COUNT = 5;
|
||||||
|
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
|
||||||
|
|
||||||
|
const wrapLabel = (label: string) => ({ label });
|
||||||
|
|
||||||
|
const setFunctionMove = (suggestion: CompletionItem): CompletionItem => {
|
||||||
|
suggestion.move = -1;
|
||||||
|
return suggestion;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function addHistoryMetadata(item: CompletionItem, history: any[]): CompletionItem {
|
||||||
|
const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
|
||||||
|
const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label);
|
||||||
|
const count = historyForItem.length;
|
||||||
|
const recent = historyForItem[0];
|
||||||
|
let hint = `Queried ${count} times in the last 24h.`;
|
||||||
|
if (recent) {
|
||||||
|
const lastQueried = moment(recent.ts).fromNow();
|
||||||
|
hint = `${hint} Last queried ${lastQueried}.`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
documentation: hint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class PromQlLanguageProvider extends LanguageProvider {
|
||||||
|
histogramMetrics?: string[];
|
||||||
|
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
|
||||||
|
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
|
||||||
|
metrics?: string[];
|
||||||
|
logLabelOptions: any[];
|
||||||
|
supportsLogs?: boolean;
|
||||||
|
started: boolean;
|
||||||
|
|
||||||
|
constructor(datasource: any, initialValues?: any) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.datasource = datasource;
|
||||||
|
this.histogramMetrics = [];
|
||||||
|
this.labelKeys = {};
|
||||||
|
this.labelValues = {};
|
||||||
|
this.metrics = [];
|
||||||
|
this.supportsLogs = false;
|
||||||
|
this.started = false;
|
||||||
|
|
||||||
|
Object.assign(this, initialValues);
|
||||||
|
}
|
||||||
|
// Strip syntax chars
|
||||||
|
cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
|
||||||
|
|
||||||
|
getSyntax() {
|
||||||
|
return PromqlSyntax;
|
||||||
|
}
|
||||||
|
|
||||||
|
request = url => {
|
||||||
|
return this.datasource.metadataRequest(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
start = () => {
|
||||||
|
if (!this.started) {
|
||||||
|
this.started = true;
|
||||||
|
return Promise.all([this.fetchMetricNames(), this.fetchHistogramMetrics()]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep this DOM-free for testing
|
||||||
|
provideCompletionItems({ prefix, wrapperClasses, text }: TypeaheadInput, context?: any): TypeaheadOutput {
|
||||||
|
// Syntax spans have 3 classes by default. More indicate a recognized token
|
||||||
|
const tokenRecognized = wrapperClasses.length > 3;
|
||||||
|
// Determine candidates by CSS context
|
||||||
|
if (_.includes(wrapperClasses, 'context-range')) {
|
||||||
|
// Suggestions for metric[|]
|
||||||
|
return this.getRangeCompletionItems();
|
||||||
|
} else if (_.includes(wrapperClasses, 'context-labels')) {
|
||||||
|
// Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
|
||||||
|
return this.getLabelCompletionItems.apply(this, arguments);
|
||||||
|
} else if (_.includes(wrapperClasses, 'context-aggregation')) {
|
||||||
|
return this.getAggregationCompletionItems.apply(this, arguments);
|
||||||
|
} else if (
|
||||||
|
// Show default suggestions in a couple of scenarios
|
||||||
|
(prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
|
||||||
|
(prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
|
||||||
|
text.match(/[+\-*/^%]/) // Anything after binary operator
|
||||||
|
) {
|
||||||
|
return this.getEmptyCompletionItems(context || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getEmptyCompletionItems(context: any): TypeaheadOutput {
|
||||||
|
const { history } = context;
|
||||||
|
const { metrics } = this;
|
||||||
|
const suggestions: CompletionItemGroup[] = [];
|
||||||
|
|
||||||
|
if (history && history.length > 0) {
|
||||||
|
const historyItems = _.chain(history)
|
||||||
|
.uniqBy('query')
|
||||||
|
.take(HISTORY_ITEM_COUNT)
|
||||||
|
.map(h => h.query)
|
||||||
|
.map(wrapLabel)
|
||||||
|
.map(item => addHistoryMetadata(item, history))
|
||||||
|
.value();
|
||||||
|
|
||||||
|
suggestions.push({
|
||||||
|
prefixMatch: true,
|
||||||
|
skipSort: true,
|
||||||
|
label: 'History',
|
||||||
|
items: historyItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions.push({
|
||||||
|
prefixMatch: true,
|
||||||
|
label: 'Functions',
|
||||||
|
items: FUNCTIONS.map(setFunctionMove),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (metrics) {
|
||||||
|
suggestions.push({
|
||||||
|
label: 'Metrics',
|
||||||
|
items: metrics.map(wrapLabel),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { suggestions };
|
||||||
|
}
|
||||||
|
|
||||||
|
getRangeCompletionItems(): TypeaheadOutput {
|
||||||
|
return {
|
||||||
|
context: 'context-range',
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
label: 'Range vector',
|
||||||
|
items: [...RATE_RANGES].map(wrapLabel),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getAggregationCompletionItems({ value }: TypeaheadInput): TypeaheadOutput {
|
||||||
|
let refresher: Promise<any> = null;
|
||||||
|
const suggestions: CompletionItemGroup[] = [];
|
||||||
|
|
||||||
|
// sum(foo{bar="1"}) by (|)
|
||||||
|
const line = value.anchorBlock.getText();
|
||||||
|
const cursorOffset: number = value.anchorOffset;
|
||||||
|
// sum(foo{bar="1"}) by (
|
||||||
|
const leftSide = line.slice(0, cursorOffset);
|
||||||
|
const openParensAggregationIndex = leftSide.lastIndexOf('(');
|
||||||
|
const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('(');
|
||||||
|
const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
|
||||||
|
// foo{bar="1"}
|
||||||
|
const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
|
||||||
|
const selector = parseSelector(selectorString, selectorString.length - 2).selector;
|
||||||
|
|
||||||
|
const labelKeys = this.labelKeys[selector];
|
||||||
|
if (labelKeys) {
|
||||||
|
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
|
||||||
|
} else {
|
||||||
|
refresher = this.fetchSeriesLabels(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
refresher,
|
||||||
|
suggestions,
|
||||||
|
context: 'context-aggregation',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): TypeaheadOutput {
|
||||||
|
let context: string;
|
||||||
|
let refresher: Promise<any> = null;
|
||||||
|
const suggestions: CompletionItemGroup[] = [];
|
||||||
|
const line = value.anchorBlock.getText();
|
||||||
|
const cursorOffset: number = value.anchorOffset;
|
||||||
|
|
||||||
|
// Get normalized selector
|
||||||
|
let selector;
|
||||||
|
let parsedSelector;
|
||||||
|
try {
|
||||||
|
parsedSelector = parseSelector(line, cursorOffset);
|
||||||
|
selector = parsedSelector.selector;
|
||||||
|
} catch {
|
||||||
|
selector = EMPTY_SELECTOR;
|
||||||
|
}
|
||||||
|
const containsMetric = selector.indexOf('__name__=') > -1;
|
||||||
|
const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
|
||||||
|
|
||||||
|
if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
|
||||||
|
// Label values
|
||||||
|
if (labelKey && this.labelValues[selector] && this.labelValues[selector][labelKey]) {
|
||||||
|
const labelValues = this.labelValues[selector][labelKey];
|
||||||
|
context = 'context-label-values';
|
||||||
|
suggestions.push({
|
||||||
|
label: `Label values for "${labelKey}"`,
|
||||||
|
items: labelValues.map(wrapLabel),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Label keys
|
||||||
|
const labelKeys = this.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
|
||||||
|
if (labelKeys) {
|
||||||
|
const possibleKeys = _.difference(labelKeys, existingKeys);
|
||||||
|
if (possibleKeys.length > 0) {
|
||||||
|
context = 'context-labels';
|
||||||
|
suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query labels for selector
|
||||||
|
// Temporarily add skip for logging
|
||||||
|
if (selector && !this.labelValues[selector] && !this.supportsLogs) {
|
||||||
|
if (selector === EMPTY_SELECTOR) {
|
||||||
|
// Query label values for default labels
|
||||||
|
refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
|
||||||
|
} else {
|
||||||
|
refresher = this.fetchSeriesLabels(selector, !containsMetric);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { context, refresher, suggestions };
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchMetricNames() {
|
||||||
|
const url = '/api/v1/label/__name__/values';
|
||||||
|
try {
|
||||||
|
const res = await this.request(url);
|
||||||
|
const body = await (res.data || res.json());
|
||||||
|
this.metrics = body.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchHistogramMetrics() {
|
||||||
|
await this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true);
|
||||||
|
const histogramSeries = this.labelValues[HISTOGRAM_SELECTOR];
|
||||||
|
if (histogramSeries && histogramSeries['__name__']) {
|
||||||
|
this.histogramMetrics = histogramSeries['__name__'].slice().sort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temporarily here while reusing this field for logging
|
||||||
|
async fetchLogLabels() {
|
||||||
|
const url = '/api/prom/label';
|
||||||
|
try {
|
||||||
|
const res = await this.request(url);
|
||||||
|
const body = await (res.data || res.json());
|
||||||
|
const labelKeys = body.data.slice().sort();
|
||||||
|
const labelKeysBySelector = {
|
||||||
|
...this.labelKeys,
|
||||||
|
[EMPTY_SELECTOR]: labelKeys,
|
||||||
|
};
|
||||||
|
const labelValuesByKey = {};
|
||||||
|
this.logLabelOptions = [];
|
||||||
|
for (const key of labelKeys) {
|
||||||
|
const valuesUrl = `/api/prom/label/${key}/values`;
|
||||||
|
const res = await this.request(valuesUrl);
|
||||||
|
const body = await (res.data || res.json());
|
||||||
|
const values = body.data.slice().sort();
|
||||||
|
labelValuesByKey[key] = values;
|
||||||
|
this.logLabelOptions.push({
|
||||||
|
label: key,
|
||||||
|
value: key,
|
||||||
|
children: values.map(value => ({ label: value, value })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.labelValues = { [EMPTY_SELECTOR]: labelValuesByKey };
|
||||||
|
this.labelKeys = labelKeysBySelector;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchLabelValues(key: string) {
|
||||||
|
const url = `/api/v1/label/${key}/values`;
|
||||||
|
try {
|
||||||
|
const res = await this.request(url);
|
||||||
|
const body = await (res.data || res.json());
|
||||||
|
const exisingValues = this.labelValues[EMPTY_SELECTOR];
|
||||||
|
const values = {
|
||||||
|
...exisingValues,
|
||||||
|
[key]: body.data,
|
||||||
|
};
|
||||||
|
this.labelValues = {
|
||||||
|
...this.labelValues,
|
||||||
|
[EMPTY_SELECTOR]: values,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchSeriesLabels(name: string, withName?: boolean) {
|
||||||
|
const url = `/api/v1/series?match[]=${name}`;
|
||||||
|
try {
|
||||||
|
const res = await this.request(url);
|
||||||
|
const body = await (res.data || res.json());
|
||||||
|
const { keys, values } = processLabels(body.data, withName);
|
||||||
|
this.labelKeys = {
|
||||||
|
...this.labelKeys,
|
||||||
|
[name]: keys,
|
||||||
|
};
|
||||||
|
this.labelValues = {
|
||||||
|
...this.labelValues,
|
||||||
|
[name]: values,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -23,9 +23,6 @@ export function processLabels(labels, withName = false) {
|
|||||||
return { values, keys: Object.keys(values) };
|
return { values, keys: Object.keys(values) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip syntax chars
|
|
||||||
export const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
|
|
||||||
|
|
||||||
// const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/;
|
// const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/;
|
||||||
const selectorRegexp = /\{[^}]*?\}/;
|
const selectorRegexp = /\{[^}]*?\}/;
|
||||||
const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g;
|
const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g;
|
@ -1,6 +1,8 @@
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
export function getQueryHints(query: string, series?: any[], datasource?: any): any[] {
|
import { QueryHint } from 'app/types/explore';
|
||||||
|
|
||||||
|
export function getQueryHints(query: string, series?: any[], datasource?: any): QueryHint[] {
|
||||||
const hints = [];
|
const hints = [];
|
||||||
|
|
||||||
// ..._bucket metric needs a histogram_quantile()
|
// ..._bucket metric needs a histogram_quantile()
|
||||||
|
@ -0,0 +1,202 @@
|
|||||||
|
import Plain from 'slate-plain-serializer';
|
||||||
|
|
||||||
|
import LanguageProvider from '../language_provider';
|
||||||
|
|
||||||
|
describe('Language completion provider', () => {
|
||||||
|
const datasource = {
|
||||||
|
metadataRequest: () => ({ data: { data: [] } }),
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns default suggestions on emtpty context', () => {
|
||||||
|
const instance = new LanguageProvider(datasource);
|
||||||
|
const result = instance.provideCompletionItems({ 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 = new LanguageProvider(datasource);
|
||||||
|
const result = instance.provideCompletionItems({ 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 = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
|
||||||
|
const result = instance.provideCompletionItems({ 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 = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
|
||||||
|
const result = instance.provideCompletionItems({ 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 = new LanguageProvider(datasource);
|
||||||
|
const value = Plain.deserialize('{}');
|
||||||
|
const range = value.selection.merge({
|
||||||
|
anchorOffset: 1,
|
||||||
|
});
|
||||||
|
const valueWithSelection = value.change().select(range).value;
|
||||||
|
const result = instance.provideCompletionItems({
|
||||||
|
text: '',
|
||||||
|
prefix: '',
|
||||||
|
wrapperClasses: ['context-labels'],
|
||||||
|
value: valueWithSelection,
|
||||||
|
});
|
||||||
|
expect(result.context).toBe('context-labels');
|
||||||
|
expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns label suggestions on label context and metric', () => {
|
||||||
|
const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } });
|
||||||
|
const value = Plain.deserialize('metric{}');
|
||||||
|
const range = value.selection.merge({
|
||||||
|
anchorOffset: 7,
|
||||||
|
});
|
||||||
|
const valueWithSelection = value.change().select(range).value;
|
||||||
|
const result = instance.provideCompletionItems({
|
||||||
|
text: '',
|
||||||
|
prefix: '',
|
||||||
|
wrapperClasses: ['context-labels'],
|
||||||
|
value: valueWithSelection,
|
||||||
|
});
|
||||||
|
expect(result.context).toBe('context-labels');
|
||||||
|
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns label suggestions on label context but leaves out labels that already exist', () => {
|
||||||
|
const instance = new LanguageProvider(datasource, {
|
||||||
|
labelKeys: { '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] },
|
||||||
|
});
|
||||||
|
const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}');
|
||||||
|
const range = value.selection.merge({
|
||||||
|
anchorOffset: 36,
|
||||||
|
});
|
||||||
|
const valueWithSelection = value.change().select(range).value;
|
||||||
|
const result = instance.provideCompletionItems({
|
||||||
|
text: '',
|
||||||
|
prefix: '',
|
||||||
|
wrapperClasses: ['context-labels'],
|
||||||
|
value: valueWithSelection,
|
||||||
|
});
|
||||||
|
expect(result.context).toBe('context-labels');
|
||||||
|
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns label value suggestions inside a label value context after a negated matching operator', () => {
|
||||||
|
const instance = new LanguageProvider(datasource, {
|
||||||
|
labelKeys: { '{}': ['label'] },
|
||||||
|
labelValues: { '{}': { label: ['a', 'b', 'c'] } },
|
||||||
|
});
|
||||||
|
const value = Plain.deserialize('{label!=}');
|
||||||
|
const range = value.selection.merge({ anchorOffset: 8 });
|
||||||
|
const valueWithSelection = value.change().select(range).value;
|
||||||
|
const result = instance.provideCompletionItems({
|
||||||
|
text: '!=',
|
||||||
|
prefix: '',
|
||||||
|
wrapperClasses: ['context-labels'],
|
||||||
|
labelKey: 'label',
|
||||||
|
value: valueWithSelection,
|
||||||
|
});
|
||||||
|
expect(result.context).toBe('context-label-values');
|
||||||
|
expect(result.suggestions).toEqual([
|
||||||
|
{
|
||||||
|
items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }],
|
||||||
|
label: 'Label values for "label"',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a refresher on label context and unavailable metric', () => {
|
||||||
|
const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="foo"}': ['bar'] } });
|
||||||
|
const value = Plain.deserialize('metric{}');
|
||||||
|
const range = value.selection.merge({
|
||||||
|
anchorOffset: 7,
|
||||||
|
});
|
||||||
|
const valueWithSelection = value.change().select(range).value;
|
||||||
|
const result = instance.provideCompletionItems({
|
||||||
|
text: '',
|
||||||
|
prefix: '',
|
||||||
|
wrapperClasses: ['context-labels'],
|
||||||
|
value: valueWithSelection,
|
||||||
|
});
|
||||||
|
expect(result.context).toBeUndefined();
|
||||||
|
expect(result.refresher).toBeInstanceOf(Promise);
|
||||||
|
expect(result.suggestions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns label values on label context when given a metric and a label key', () => {
|
||||||
|
const instance = new LanguageProvider(datasource, {
|
||||||
|
labelKeys: { '{__name__="metric"}': ['bar'] },
|
||||||
|
labelValues: { '{__name__="metric"}': { bar: ['baz'] } },
|
||||||
|
});
|
||||||
|
const value = Plain.deserialize('metric{bar=ba}');
|
||||||
|
const range = value.selection.merge({
|
||||||
|
anchorOffset: 13,
|
||||||
|
});
|
||||||
|
const valueWithSelection = value.change().select(range).value;
|
||||||
|
const result = instance.provideCompletionItems({
|
||||||
|
text: '=ba',
|
||||||
|
prefix: 'ba',
|
||||||
|
wrapperClasses: ['context-labels'],
|
||||||
|
labelKey: 'bar',
|
||||||
|
value: valueWithSelection,
|
||||||
|
});
|
||||||
|
expect(result.context).toBe('context-label-values');
|
||||||
|
expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns label suggestions on aggregation context and metric w/ selector', () => {
|
||||||
|
const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric",foo="xx"}': ['bar'] } });
|
||||||
|
const value = Plain.deserialize('sum(metric{foo="xx"}) by ()');
|
||||||
|
const range = value.selection.merge({
|
||||||
|
anchorOffset: 26,
|
||||||
|
});
|
||||||
|
const valueWithSelection = value.change().select(range).value;
|
||||||
|
const result = instance.provideCompletionItems({
|
||||||
|
text: '',
|
||||||
|
prefix: '',
|
||||||
|
wrapperClasses: ['context-aggregation'],
|
||||||
|
value: valueWithSelection,
|
||||||
|
});
|
||||||
|
expect(result.context).toBe('context-aggregation');
|
||||||
|
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns label suggestions on aggregation context and metric w/o selector', () => {
|
||||||
|
const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } });
|
||||||
|
const value = Plain.deserialize('sum(metric) by ()');
|
||||||
|
const range = value.selection.merge({
|
||||||
|
anchorOffset: 16,
|
||||||
|
});
|
||||||
|
const valueWithSelection = value.change().select(range).value;
|
||||||
|
const result = instance.provideCompletionItems({
|
||||||
|
text: '',
|
||||||
|
prefix: '',
|
||||||
|
wrapperClasses: ['context-aggregation'],
|
||||||
|
value: valueWithSelection,
|
||||||
|
});
|
||||||
|
expect(result.context).toBe('context-aggregation');
|
||||||
|
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,4 +1,4 @@
|
|||||||
import { parseSelector } from './prometheus';
|
import { parseSelector } from '../language_utils';
|
||||||
|
|
||||||
describe('parseSelector()', () => {
|
describe('parseSelector()', () => {
|
||||||
let parsed;
|
let parsed;
|
@ -1,3 +1,75 @@
|
|||||||
|
import { Value } from 'slate';
|
||||||
|
|
||||||
|
export interface CompletionItem {
|
||||||
|
/**
|
||||||
|
* The label of this completion item. By default
|
||||||
|
* this is also the text that is inserted when selecting
|
||||||
|
* this completion.
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
/**
|
||||||
|
* The kind of this completion item. Based on the kind
|
||||||
|
* an icon is chosen by the editor.
|
||||||
|
*/
|
||||||
|
kind?: string;
|
||||||
|
/**
|
||||||
|
* A human-readable string with additional information
|
||||||
|
* about this item, like type or symbol information.
|
||||||
|
*/
|
||||||
|
detail?: string;
|
||||||
|
/**
|
||||||
|
* A human-readable string, can be Markdown, that represents a doc-comment.
|
||||||
|
*/
|
||||||
|
documentation?: string;
|
||||||
|
/**
|
||||||
|
* A string that should be used when comparing this item
|
||||||
|
* with other items. When `falsy` the `label` is used.
|
||||||
|
*/
|
||||||
|
sortText?: string;
|
||||||
|
/**
|
||||||
|
* A string that should be used when filtering a set of
|
||||||
|
* completion items. When `falsy` the `label` is used.
|
||||||
|
*/
|
||||||
|
filterText?: string;
|
||||||
|
/**
|
||||||
|
* A string or snippet that should be inserted in a document when selecting
|
||||||
|
* this completion. When `falsy` the `label` is used.
|
||||||
|
*/
|
||||||
|
insertText?: string;
|
||||||
|
/**
|
||||||
|
* Delete number of characters before the caret position,
|
||||||
|
* by default the letters from the beginning of the word.
|
||||||
|
*/
|
||||||
|
deleteBackwards?: number;
|
||||||
|
/**
|
||||||
|
* Number of steps to move after the insertion, can be negative.
|
||||||
|
*/
|
||||||
|
move?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletionItemGroup {
|
||||||
|
/**
|
||||||
|
* Label that will be displayed for all entries of this group.
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
/**
|
||||||
|
* List of suggestions of this group.
|
||||||
|
*/
|
||||||
|
items: CompletionItem[];
|
||||||
|
/**
|
||||||
|
* If true, match only by prefix (and not mid-word).
|
||||||
|
*/
|
||||||
|
prefixMatch?: boolean;
|
||||||
|
/**
|
||||||
|
* If true, do not filter items in this group based on the search.
|
||||||
|
*/
|
||||||
|
skipFilter?: boolean;
|
||||||
|
/**
|
||||||
|
* If true, do not sort items.
|
||||||
|
*/
|
||||||
|
skipSort?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ExploreDatasource {
|
interface ExploreDatasource {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
@ -8,6 +80,26 @@ export interface HistoryItem {
|
|||||||
query: string;
|
query: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export abstract class LanguageProvider {
|
||||||
|
datasource: any;
|
||||||
|
request: (url) => Promise<any>;
|
||||||
|
start: () => Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TypeaheadInput {
|
||||||
|
text: string;
|
||||||
|
prefix: string;
|
||||||
|
wrapperClasses: string[];
|
||||||
|
labelKey?: string;
|
||||||
|
value?: Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TypeaheadOutput {
|
||||||
|
context?: string;
|
||||||
|
refresher?: Promise<{}>;
|
||||||
|
suggestions: CompletionItemGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Range {
|
export interface Range {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
@ -18,11 +110,28 @@ export interface Query {
|
|||||||
key?: string;
|
key?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QueryFix {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
action?: QueryFixAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryFixAction {
|
||||||
|
type: string;
|
||||||
|
query?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryHint {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
fix?: QueryFix;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QueryTransaction {
|
export interface QueryTransaction {
|
||||||
id: string;
|
id: string;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
hints?: any[];
|
hints?: QueryHint[];
|
||||||
latency: number;
|
latency: number;
|
||||||
options: any;
|
options: any;
|
||||||
query: string;
|
query: string;
|
||||||
|
Loading…
Reference in New Issue
Block a user