Merge branch 'master' of github.com:grafana/grafana

This commit is contained in:
Torkel Ödegaard 2018-10-28 11:34:25 -07:00
commit 7863d2d882
12 changed files with 377 additions and 31 deletions

View File

@ -100,12 +100,12 @@ display name, especially if the display name contains spaces or special
characters. Make sure you always use the group or subgroup name as it appears characters. Make sure you always use the group or subgroup name as it appears
in the URL of the group or subgroup. in the URL of the group or subgroup.
Here's a complete example with `alloed_sign_up` enabled, and access limited to Here's a complete example with `allow_sign_up` enabled, and access limited to
the `example` and `foo/bar` groups: the `example` and `foo/bar` groups:
```ini ```ini
[auth.gitlab] [auth.gitlab]
enabled = false enabled = true
allow_sign_up = true allow_sign_up = true
client_id = GITLAB_APPLICATION_ID client_id = GITLAB_APPLICATION_ID
client_secret = GITLAB_SECRET client_secret = GITLAB_SECRET

View File

@ -373,9 +373,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
this.onModifyQueries({ type: 'ADD_FILTER', key: columnKey, value: rowValue }); this.onModifyQueries({ type: 'ADD_FILTER', key: columnKey, value: rowValue });
}; };
onModifyQueries = (action: object, index?: number) => { onModifyQueries = (action, index?: number) => {
const { datasource } = this.state; const { datasource } = this.state;
if (datasource && datasource.modifyQuery) { if (datasource && datasource.modifyQuery) {
const preventSubmit = action.preventSubmit;
this.setState( this.setState(
state => { state => {
const { queries, queryTransactions } = state; const { queries, queryTransactions } = state;
@ -391,16 +392,26 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
nextQueryTransactions = []; nextQueryTransactions = [];
} else { } else {
// Modify query only at index // Modify query only at index
nextQueries = [ nextQueries = queries.map((q, i) => {
...queries.slice(0, index), // Synchronise all queries with local query cache to ensure consistency
{ q.query = this.queryExpressions[i];
key: generateQueryKey(index), return i === index
query: datasource.modifyQuery(this.queryExpressions[index], action), ? {
}, key: generateQueryKey(index),
...queries.slice(index + 1), query: datasource.modifyQuery(q.query, action),
]; }
// Discard transactions related to row query : q;
nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); });
nextQueryTransactions = queryTransactions
// Consume the hint corresponding to the action
.map(qt => {
if (qt.hints != null && qt.rowIndex === index) {
qt.hints = qt.hints.filter(hint => hint.fix.action !== action);
}
return qt;
})
// Preserve previous row query transaction to keep results visible if next query is incomplete
.filter(qt => preventSubmit || qt.rowIndex !== index);
} }
this.queryExpressions = nextQueries.map(q => q.query); this.queryExpressions = nextQueries.map(q => q.query);
return { return {
@ -408,7 +419,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
queryTransactions: nextQueryTransactions, queryTransactions: nextQueryTransactions,
}; };
}, },
() => this.onSubmit() // Accepting certain fixes do not result in a well-formed query which should not be submitted
!preventSubmit ? () => this.onSubmit() : null
); );
} }
}; };

View File

@ -0,0 +1,72 @@
import PlaceholdersBuffer from './PlaceholdersBuffer';
describe('PlaceholdersBuffer', () => {
it('does nothing if no placeholders are defined', () => {
const text = 'metric';
const buffer = new PlaceholdersBuffer(text);
expect(buffer.hasPlaceholders()).toBe(false);
expect(buffer.toString()).toBe(text);
expect(buffer.getNextMoveOffset()).toBe(0);
});
it('respects the traversal order of placeholders', () => {
const text = 'sum($2 offset $1) by ($3)';
const buffer = new PlaceholdersBuffer(text);
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('sum( offset ) by ()');
expect(buffer.getNextMoveOffset()).toBe(12);
buffer.setNextPlaceholderValue('1h');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('sum( offset 1h) by ()');
expect(buffer.getNextMoveOffset()).toBe(-10);
buffer.setNextPlaceholderValue('metric');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('sum(metric offset 1h) by ()');
expect(buffer.getNextMoveOffset()).toBe(16);
buffer.setNextPlaceholderValue('label');
expect(buffer.hasPlaceholders()).toBe(false);
expect(buffer.toString()).toBe('sum(metric offset 1h) by (label)');
expect(buffer.getNextMoveOffset()).toBe(0);
});
it('respects the traversal order of adjacent placeholders', () => {
const text = '$1$3$2$4';
const buffer = new PlaceholdersBuffer(text);
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('');
expect(buffer.getNextMoveOffset()).toBe(0);
buffer.setNextPlaceholderValue('1');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('1');
expect(buffer.getNextMoveOffset()).toBe(0);
buffer.setNextPlaceholderValue('2');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('12');
expect(buffer.getNextMoveOffset()).toBe(-1);
buffer.setNextPlaceholderValue('3');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('132');
expect(buffer.getNextMoveOffset()).toBe(1);
buffer.setNextPlaceholderValue('4');
expect(buffer.hasPlaceholders()).toBe(false);
expect(buffer.toString()).toBe('1324');
expect(buffer.getNextMoveOffset()).toBe(0);
});
});

View File

@ -0,0 +1,112 @@
/**
* Provides a stateful means of managing placeholders in text.
*
* Placeholders are numbers prefixed with the `$` character (e.g. `$1`).
* Each number value represents the order in which a placeholder should
* receive focus if multiple placeholders exist.
*
* Example scenario given `sum($3 offset $1) by($2)`:
* 1. `sum( offset |) by()`
* 2. `sum( offset 1h) by(|)`
* 3. `sum(| offset 1h) by (label)`
*/
export default class PlaceholdersBuffer {
private nextMoveOffset: number;
private orders: number[];
private parts: string[];
constructor(text: string) {
const result = this.parse(text);
const nextPlaceholderIndex = result.orders.length ? result.orders[0] : 0;
this.nextMoveOffset = this.getOffsetBetween(result.parts, 0, nextPlaceholderIndex);
this.orders = result.orders;
this.parts = result.parts;
}
clearPlaceholders() {
this.nextMoveOffset = 0;
this.orders = [];
}
getNextMoveOffset(): number {
return this.nextMoveOffset;
}
hasPlaceholders(): boolean {
return this.orders.length > 0;
}
setNextPlaceholderValue(value: string) {
if (this.orders.length === 0) {
return;
}
const currentPlaceholderIndex = this.orders[0];
this.parts[currentPlaceholderIndex] = value;
this.orders = this.orders.slice(1);
if (this.orders.length === 0) {
this.nextMoveOffset = 0;
return;
}
const nextPlaceholderIndex = this.orders[0];
// Case should never happen but handle it gracefully in case
if (currentPlaceholderIndex === nextPlaceholderIndex) {
this.nextMoveOffset = 0;
return;
}
const backwardMove = currentPlaceholderIndex > nextPlaceholderIndex;
const indices = backwardMove
? { start: nextPlaceholderIndex + 1, end: currentPlaceholderIndex + 1 }
: { start: currentPlaceholderIndex + 1, end: nextPlaceholderIndex };
this.nextMoveOffset = (backwardMove ? -1 : 1) * this.getOffsetBetween(this.parts, indices.start, indices.end);
}
toString(): string {
return this.parts.join('');
}
private getOffsetBetween(parts: string[], startIndex: number, endIndex: number) {
return parts.slice(startIndex, endIndex).reduce((offset, part) => offset + part.length, 0);
}
private parse(text: string): ParseResult {
const placeholderRegExp = /\$(\d+)/g;
const parts = [];
const orders = [];
let textOffset = 0;
while (true) {
const match = placeholderRegExp.exec(text);
if (!match) {
break;
}
const part = text.slice(textOffset, match.index);
parts.push(part);
// Accounts for placeholders at text boundaries
if (part !== '') {
parts.push('');
}
const order = parseInt(match[1], 10);
orders.push({ index: parts.length - 1, order });
textOffset += part.length + match.length;
}
// Ensures string serialisation still works if no placeholders were parsed
// and also accounts for the remainder of text with placeholders
parts.push(text.slice(textOffset));
return {
// Placeholder values do not necessarily appear sequentially so sort the
// indices to traverse in priority order
orders: orders.sort((o1, o2) => o1.order - o2.order).map(o => o.index),
parts,
};
}
}
type ParseResult = {
/**
* Indices to placeholder items in `parts` in traversal order.
*/
orders: number[];
/**
* Parts comprising the original text with placeholders occupying distinct items.
*/
parts: string[];
};

View File

@ -12,6 +12,7 @@ import NewlinePlugin from './slate-plugins/newline';
import Typeahead from './Typeahead'; import Typeahead from './Typeahead';
import { makeFragment, makeValue } from './Value'; import { makeFragment, makeValue } from './Value';
import PlaceholdersBuffer from './PlaceholdersBuffer';
export const TYPEAHEAD_DEBOUNCE = 100; export const TYPEAHEAD_DEBOUNCE = 100;
@ -61,12 +62,15 @@ export interface TypeaheadInput {
class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadFieldState> { class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadFieldState> {
menuEl: HTMLElement | null; menuEl: HTMLElement | null;
placeholdersBuffer: PlaceholdersBuffer;
plugins: any[]; plugins: any[];
resetTimer: any; resetTimer: any;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.placeholdersBuffer = new PlaceholdersBuffer(props.initialValue || '');
// Base plugins // Base plugins
this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins]; this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins];
@ -76,7 +80,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
typeaheadIndex: 0, typeaheadIndex: 0,
typeaheadPrefix: '', typeaheadPrefix: '',
typeaheadText: '', typeaheadText: '',
value: makeValue(props.initialValue || '', props.syntax), value: makeValue(this.placeholdersBuffer.toString(), props.syntax),
}; };
} }
@ -101,12 +105,14 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
componentWillReceiveProps(nextProps: TypeaheadFieldProps) { componentWillReceiveProps(nextProps: TypeaheadFieldProps) {
if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) { if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) {
// Need a bogus edit to re-render the editor after syntax has fully loaded // Need a bogus edit to re-render the editor after syntax has fully loaded
this.onChange( const change = this.state.value
this.state.value .change()
.change() .insertText(' ')
.insertText(' ') .deleteBackward();
.deleteBackward() if (this.placeholdersBuffer.hasPlaceholders()) {
); change.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
}
this.onChange(change);
} }
} }
@ -289,7 +295,17 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
} }
const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex); const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
this.applyTypeahead(change, suggestion); const nextChange = this.applyTypeahead(change, suggestion);
const insertTextOperation = nextChange.operations.find(operation => operation.type === 'insert_text');
if (insertTextOperation) {
const suggestionText = insertTextOperation.text;
this.placeholdersBuffer.setNextPlaceholderValue(suggestionText);
if (this.placeholdersBuffer.hasPlaceholders()) {
nextChange.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
}
}
return true; return true;
} }
break; break;
@ -336,6 +352,8 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
// If we dont wait here, menu clicks wont work because the menu // If we dont wait here, menu clicks wont work because the menu
// will be gone. // will be gone.
this.resetTimer = setTimeout(this.resetTypeahead, 100); this.resetTimer = setTimeout(this.resetTypeahead, 100);
// Disrupting placeholder entry wipes all remaining placeholders needing input
this.placeholdersBuffer.clearPlaceholders();
if (onBlur) { if (onBlur) {
onBlur(); onBlur();
} }

View File

@ -464,6 +464,9 @@ export class PrometheusDatasource {
case 'ADD_RATE': { case 'ADD_RATE': {
return `rate(${query}[5m])`; return `rate(${query}[5m])`;
} }
case 'ADD_SUM': {
return `sum(${query.trim()}) by ($1)`;
}
case 'EXPAND_RULES': { case 'EXPAND_RULES': {
const mapping = action.mapping; const mapping = action.mapping;
if (mapping) { if (mapping) {

View File

@ -162,16 +162,29 @@ export default class PromQlLanguageProvider extends LanguageProvider {
let refresher: Promise<any> = null; let refresher: Promise<any> = null;
const suggestions: CompletionItemGroup[] = []; const suggestions: CompletionItemGroup[] = [];
// sum(foo{bar="1"}) by (|) // Stitch all query lines together to support multi-line queries
const line = value.anchorBlock.getText(); let queryOffset;
const cursorOffset: number = value.anchorOffset; const queryText = value.document.getBlocks().reduce((text, block) => {
// sum(foo{bar="1"}) by ( const blockText = block.getText();
const leftSide = line.slice(0, cursorOffset); if (value.anchorBlock.key === block.key) {
// Newline characters are not accounted for but this is irrelevant
// for the purpose of extracting the selector string
queryOffset = value.anchorOffset + text.length;
}
text += blockText;
return text;
}, '');
const leftSide = queryText.slice(0, queryOffset);
const openParensAggregationIndex = leftSide.lastIndexOf('('); const openParensAggregationIndex = leftSide.lastIndexOf('(');
const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('('); const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('(');
const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex; const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
// foo{bar="1"}
const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex); let selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
// Range vector syntax not accounted for by subsequent parse so discard it if present
selectorString = selectorString.replace(/\[[^\]]+\]$/, '');
const selector = parseSelector(selectorString, selectorString.length - 2).selector; const selector = parseSelector(selectorString, selectorString.length - 2).selector;
const labelKeys = this.labelKeys[selector]; const labelKeys = this.labelKeys[selector];

View File

@ -2,6 +2,11 @@ import _ from 'lodash';
import { QueryHint } from 'app/types/explore'; import { QueryHint } from 'app/types/explore';
/**
* Number of time series results needed before starting to suggest sum aggregation hints
*/
export const SUM_HINT_THRESHOLD_COUNT = 20;
export function getQueryHints(query: string, series?: any[], datasource?: any): QueryHint[] { export function getQueryHints(query: string, series?: any[], datasource?: any): QueryHint[] {
const hints = []; const hints = [];
@ -90,5 +95,24 @@ export function getQueryHints(query: string, series?: any[], datasource?: any):
}); });
} }
} }
if (series.length >= SUM_HINT_THRESHOLD_COUNT) {
const simpleMetric = query.trim().match(/^\w+$/);
if (simpleMetric) {
hints.push({
type: 'ADD_SUM',
label: 'Many time series results returned.',
fix: {
label: 'Consider aggregating with sum().',
action: {
type: 'ADD_SUM',
query: query,
preventSubmit: true,
},
},
});
}
}
return hints.length > 0 ? hints : null; return hints.length > 0 ? hints : null;
} }

View File

@ -198,5 +198,76 @@ describe('Language completion provider', () => {
expect(result.context).toBe('context-aggregation'); expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
}); });
it('returns label suggestions inside a multi-line aggregation context', () => {
const instance = new LanguageProvider(datasource, {
labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] },
});
const value = Plain.deserialize('sum(\nmetric\n)\nby ()');
const aggregationTextBlock = value.document.getBlocksAsArray()[3];
const range = value.selection.moveToStartOf(aggregationTextBlock).merge({ anchorOffset: 4 });
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: 'label1' }, { label: 'label2' }, { label: 'label3' }],
label: 'Labels',
},
]);
});
it('returns label suggestions inside an aggregation context with a range vector', () => {
const instance = new LanguageProvider(datasource, {
labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] },
});
const value = Plain.deserialize('sum(rate(metric[1h])) 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: 'label1' }, { label: 'label2' }, { label: 'label3' }],
label: 'Labels',
},
]);
});
it('returns label suggestions inside an aggregation context with a range vector and label', () => {
const instance = new LanguageProvider(datasource, {
labelKeys: { '{__name__="metric",label1="value"}': ['label1', 'label2', 'label3'] },
});
const value = Plain.deserialize('sum(rate(metric{label1="value"}[1h])) by ()');
const range = value.selection.merge({
anchorOffset: 42,
});
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: 'label1' }, { label: 'label2' }, { label: 'label3' }],
label: 'Labels',
},
]);
});
}); });
}); });

View File

@ -1,4 +1,4 @@
import { getQueryHints } from '../query_hints'; import { getQueryHints, SUM_HINT_THRESHOLD_COUNT } from '../query_hints';
describe('getQueryHints()', () => { describe('getQueryHints()', () => {
it('returns no hints for no series', () => { it('returns no hints for no series', () => {
@ -79,4 +79,25 @@ describe('getQueryHints()', () => {
}, },
}); });
}); });
it('returns a sum hint when many time series results are returned for a simple metric', () => {
const seriesCount = SUM_HINT_THRESHOLD_COUNT;
const series = Array.from({ length: seriesCount }, _ => ({
datapoints: [[0, 0], [0, 0]],
}));
const hints = getQueryHints('metric', series);
expect(hints.length).toBe(1);
expect(hints[0]).toMatchObject({
type: 'ADD_SUM',
label: 'Many time series results returned.',
fix: {
label: 'Consider aggregating with sum().',
action: {
type: 'ADD_SUM',
query: 'metric',
preventSubmit: true,
},
},
});
});
}); });

View File

@ -114,7 +114,6 @@ export default class StackdriverDatasource {
if (!queryRes.series) { if (!queryRes.series) {
return; return;
} }
this.projectName = queryRes.meta.defaultProject;
const unit = this.resolvePanelUnitFromTargets(options.targets); const unit = this.resolvePanelUnitFromTargets(options.targets);
queryRes.series.forEach(series => { queryRes.series.forEach(series => {
let timeSerie: any = { let timeSerie: any = {

View File

@ -119,6 +119,7 @@ export interface QueryFix {
export interface QueryFixAction { export interface QueryFixAction {
type: string; type: string;
query?: string; query?: string;
preventSubmit?: boolean;
} }
export interface QueryHint { export interface QueryHint {