Cloudwatch Logs: Add autosuggest to logs query editor (#71800)

* Cloudwatch: Add autosuggest to logs query editor

* move getSuggestion to LogsCompletionItemProvider

* remove debug stuff

* add tests

* fix lint
This commit is contained in:
Kevin Yu 2023-07-18 14:26:11 -07:00 committed by GitHub
parent 4ece133fce
commit 44a1f10b10
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 278 additions and 0 deletions

View File

@ -0,0 +1,17 @@
import { monacoTypes } from '@grafana/ui';
import { LogsTokenTypes } from '../../language/logs/completion/types';
import { CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID } from '../../language/logs/definition';
export const filterQuery = {
query: `filter logGroup `,
tokens: [
[
{ offset: 0, type: LogsTokenTypes.Keyword, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
{ offset: 6, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
{ offset: 7, type: LogsTokenTypes.Identifier, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
{ offset: 15, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
],
] as monacoTypes.Token[][],
position: { lineNumber: 1, column: 16 },
};

View File

@ -3,3 +3,6 @@ export { whitespaceOnlyQuery } from './whitespaceQuery';
export { commentOnlyQuery } from './commentOnlyQuery';
export { singleLineFullQuery } from './singleLineFullQuery';
export { multiLineFullQuery } from './multiLineFullQuery';
export { filterQuery } from './filterQuery';
export { newCommandQuery } from './newCommandQuery';
export { sortQuery } from './sortQuery';

View File

@ -0,0 +1,19 @@
import { monacoTypes } from '@grafana/ui';
import { LogsTokenTypes } from '../../language/logs/completion/types';
import { CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID } from '../../language/logs/definition';
export const newCommandQuery = {
query: `fields @timestamp | `,
tokens: [
[
{ offset: 0, type: LogsTokenTypes.Keyword, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
{ offset: 6, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
{ offset: 7, type: LogsTokenTypes.Identifier, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
{ offset: 17, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
{ offset: 18, type: LogsTokenTypes.Delimiter, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
{ offset: 19, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
],
] as monacoTypes.Token[][],
position: { lineNumber: 1, column: 21 },
};

View File

@ -0,0 +1,21 @@
import { monacoTypes } from '@grafana/ui';
import { LogsTokenTypes } from '../../language/logs/completion/types';
import { CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID } from '../../language/logs/definition';
export const sortQuery = {
query: `fields @timestamp | sort `,
tokens: [
[
{ offset: 0, type: LogsTokenTypes.Keyword, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
{ offset: 6, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
{ offset: 7, type: LogsTokenTypes.Identifier, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
{ offset: 17, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
{ offset: 18, type: LogsTokenTypes.Delimiter, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
{ offset: 19, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
{ offset: 20, type: LogsTokenTypes.Keyword, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
{ offset: 24, type: LogsTokenTypes.Whitespace, language: CLOUDWATCH_LOGS_LANGUAGE_DEFINITION_ID },
],
] as monacoTypes.Token[][],
position: { lineNumber: 1, column: 26 },
};

View File

@ -47,6 +47,9 @@ const MonacoMock: Monaco = {
[CloudwatchLogsTestData.commentOnlyQuery.query]: CloudwatchLogsTestData.commentOnlyQuery.tokens,
[CloudwatchLogsTestData.singleLineFullQuery.query]: CloudwatchLogsTestData.singleLineFullQuery.tokens,
[CloudwatchLogsTestData.multiLineFullQuery.query]: CloudwatchLogsTestData.multiLineFullQuery.tokens,
[CloudwatchLogsTestData.filterQuery.query]: CloudwatchLogsTestData.filterQuery.tokens,
[CloudwatchLogsTestData.newCommandQuery.query]: CloudwatchLogsTestData.newCommandQuery.tokens,
[CloudwatchLogsTestData.sortQuery.query]: CloudwatchLogsTestData.sortQuery.tokens,
};
return TestData[value];
}

View File

@ -0,0 +1,80 @@
import { CustomVariableModel } from '@grafana/data';
import { Monaco, monacoTypes } from '@grafana/ui';
import { setupMockedTemplateService, logGroupNamesVariable } from '../../../__mocks__/CloudWatchDataSource';
import { emptyQuery, filterQuery, newCommandQuery, sortQuery } from '../../../__mocks__/cloudwatch-logs-test-data';
import MonacoMock from '../../../__mocks__/monarch/Monaco';
import TextModel from '../../../__mocks__/monarch/TextModel';
import { ResourcesAPI } from '../../../resources/ResourcesAPI';
import cloudWatchLogsLanguageDefinition from '../definition';
import { LOGS_COMMANDS, LOGS_FUNCTION_OPERATORS, SORT_DIRECTION_KEYWORDS } from '../language';
import { LogsCompletionItemProvider } from './CompletionItemProvider';
jest.mock('monaco-editor/esm/vs/editor/editor.api', () => ({
Token: jest.fn((offset, type, language) => ({ offset, type, language })),
}));
const getSuggestions = async (
value: string,
position: monacoTypes.IPosition,
variables: CustomVariableModel[] = []
) => {
const setup = new LogsCompletionItemProvider(
{
getActualRegion: () => 'us-east-2',
} as ResourcesAPI,
setupMockedTemplateService(variables)
);
const monaco = MonacoMock as Monaco;
const provider = setup.getCompletionProvider(monaco, cloudWatchLogsLanguageDefinition);
const { suggestions } = await provider.provideCompletionItems(
TextModel(value) as monacoTypes.editor.ITextModel,
position
);
return suggestions;
};
describe('LogsCompletionItemProvider', () => {
describe('getSuggestions', () => {
it('returns commands for an empty query', async () => {
const suggestions = await getSuggestions(emptyQuery.query, emptyQuery.position);
const suggestionLabels = suggestions.map((s) => s.label);
expect(suggestionLabels).toEqual(expect.arrayContaining(LOGS_COMMANDS));
});
it('returns commands for a query when a new command is started', async () => {
const suggestions = await getSuggestions(newCommandQuery.query, newCommandQuery.position);
const suggestionLabels = suggestions.map((s) => s.label);
expect(suggestionLabels).toEqual(expect.arrayContaining(LOGS_COMMANDS));
});
it('returns sort order directions for the sort keyword', async () => {
const suggestions = await getSuggestions(sortQuery.query, sortQuery.position);
const suggestionLabels = suggestions.map((s) => s.label);
expect(suggestionLabels).toEqual(expect.arrayContaining(SORT_DIRECTION_KEYWORDS));
});
it('returns function suggestions after a command', async () => {
const suggestions = await getSuggestions(sortQuery.query, sortQuery.position);
const suggestionLabels = suggestions.map((s) => s.label);
expect(suggestionLabels).toEqual(expect.arrayContaining(LOGS_FUNCTION_OPERATORS));
});
it('returns `in []` snippet for the `in` keyword', async () => {
const suggestions = await getSuggestions(filterQuery.query, filterQuery.position);
const suggestionLabels = suggestions.map((s) => s.label);
expect(suggestionLabels).toEqual(expect.arrayContaining(['in []']));
});
it('returns template variables appended to list of suggestions', async () => {
const suggestions = await getSuggestions(newCommandQuery.query, newCommandQuery.position, [
logGroupNamesVariable,
]);
const suggestionLabels = suggestions.map((s) => s.label);
const expectedTemplateVariableLabel = `$${logGroupNamesVariable.name}`;
const expectedLabels = [...LOGS_COMMANDS, expectedTemplateVariableLabel];
expect(suggestionLabels).toEqual(expect.arrayContaining(expectedLabels));
});
});
});

View File

@ -0,0 +1,100 @@
import { getTemplateSrv, type TemplateSrv } from '@grafana/runtime';
import { Monaco, monacoTypes } from '@grafana/ui';
import { type ResourcesAPI } from '../../../resources/ResourcesAPI';
import { CompletionItemProvider } from '../../monarch/CompletionItemProvider';
import { LinkedToken } from '../../monarch/LinkedToken';
import { TRIGGER_SUGGEST } from '../../monarch/commands';
import { CompletionItem, CompletionItemPriority, StatementPosition, SuggestionKind } from '../../monarch/types';
import { LOGS_COMMANDS, LOGS_FUNCTION_OPERATORS, SORT_DIRECTION_KEYWORDS } from '../language';
import { getStatementPosition } from './statementPosition';
import { getSuggestionKinds } from './suggestionKinds';
import { LogsTokenTypes } from './types';
export class LogsCompletionItemProvider extends CompletionItemProvider {
constructor(resources: ResourcesAPI, templateSrv: TemplateSrv = getTemplateSrv()) {
super(resources, templateSrv);
this.getStatementPosition = getStatementPosition;
this.getSuggestionKinds = getSuggestionKinds;
this.tokenTypes = LogsTokenTypes;
}
async getSuggestions(
monaco: Monaco,
currentToken: LinkedToken | null,
suggestionKinds: SuggestionKind[],
statementPosition: StatementPosition,
position: monacoTypes.IPosition
): Promise<CompletionItem[]> {
const suggestions: CompletionItem[] = [];
const invalidRangeToken = currentToken?.isWhiteSpace() || currentToken?.isParenthesis();
const range =
invalidRangeToken || !currentToken?.range ? monaco.Range.fromPositions(position) : currentToken?.range;
function toCompletionItem(value: string, rest: Partial<CompletionItem> = {}) {
const item: monacoTypes.languages.CompletionItem = {
label: value,
insertText: value,
kind: monaco.languages.CompletionItemKind.Field,
range,
sortText: CompletionItemPriority.Medium,
...rest,
};
return item;
}
function addSuggestion(value: string, rest: Partial<CompletionItem> = {}) {
suggestions.push(toCompletionItem(value, rest));
}
for (const kind of suggestionKinds) {
switch (kind) {
case SuggestionKind.Command:
LOGS_COMMANDS.forEach((command) => {
addSuggestion(command, {
insertText: `${command} $0`,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
command: TRIGGER_SUGGEST,
});
});
break;
case SuggestionKind.Function:
LOGS_FUNCTION_OPERATORS.forEach((f) => {
addSuggestion(f, {
insertText: `${f}($0)`,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
command: TRIGGER_SUGGEST,
});
});
break;
case SuggestionKind.SortOrderDirectionKeyword:
SORT_DIRECTION_KEYWORDS.forEach((direction) => {
addSuggestion(direction, { sortText: CompletionItemPriority.High });
});
break;
case SuggestionKind.InKeyword:
addSuggestion('in []', {
insertText: 'in ["$0"]',
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
kind: monaco.languages.CompletionItemKind.Snippet,
sortText: CompletionItemPriority.High,
});
break;
}
this.templateSrv.getVariables().map((v) => {
const variable = `$${v.name}`;
addSuggestion(variable, {
range,
label: variable,
insertText: variable,
kind: monaco.languages.CompletionItemKind.Variable,
sortText: CompletionItemPriority.Low,
});
});
}
return suggestions;
}
}

View File

@ -0,0 +1,28 @@
import { StatementPosition, SuggestionKind } from '../../monarch/types';
export function getSuggestionKinds(statementPosition: StatementPosition): SuggestionKind[] {
switch (statementPosition) {
case StatementPosition.NewCommand:
return [SuggestionKind.Command];
case StatementPosition.AfterSortKeyword:
case StatementPosition.SortArg:
return [SuggestionKind.SortOrderDirectionKeyword, SuggestionKind.Function];
case StatementPosition.AfterDisplayKeyword:
case StatementPosition.AfterFieldsKeyword:
case StatementPosition.AfterFilterKeyword:
case StatementPosition.AfterStatsKeyword:
case StatementPosition.AfterLimitKeyword:
case StatementPosition.AfterParseKeyword:
case StatementPosition.AfterDedupKeyword:
case StatementPosition.CommandArg:
case StatementPosition.FunctionArg:
case StatementPosition.ArithmeticOperatorArg:
case StatementPosition.BooleanOperatorArg:
case StatementPosition.ComparisonOperatorArg:
return [SuggestionKind.Function];
case StatementPosition.FilterArg:
return [SuggestionKind.InKeyword, SuggestionKind.Function];
}
return [];
}

View File

@ -2,6 +2,8 @@ import { monacoTypes } from '@grafana/ui';
import { LanguageDefinition } from './register';
export type CompletionItem = monacoTypes.languages.CompletionItem;
export interface TokenTypes {
Parenthesis: string;
Whitespace: string;
@ -113,6 +115,11 @@ export enum SuggestionKind {
Operators,
Statistic,
Period,
// logs
Command,
Function,
InKeyword,
}
export enum CompletionItemPriority {