123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481 |
- import { sortedUniq } from 'lodash';
- import Prism, { Grammar } from 'prismjs';
- import { lastValueFrom } from 'rxjs';
- import { AbsoluteTimeRange, HistoryItem, LanguageProvider } from '@grafana/data';
- import { CompletionItemGroup, SearchFunctionType, Token, TypeaheadInput, TypeaheadOutput } from '@grafana/ui';
- import { CloudWatchDatasource } from './datasource';
- import syntax, {
- AGGREGATION_FUNCTIONS_STATS,
- BOOLEAN_FUNCTIONS,
- DATETIME_FUNCTIONS,
- FIELD_AND_FILTER_FUNCTIONS,
- IP_FUNCTIONS,
- NUMERIC_OPERATORS,
- QUERY_COMMANDS,
- STRING_FUNCTIONS,
- } from './syntax';
- import { CloudWatchQuery, TSDBResponse } from './types';
- export type CloudWatchHistoryItem = HistoryItem<CloudWatchQuery>;
- type TypeaheadContext = {
- history?: CloudWatchHistoryItem[];
- absoluteRange?: AbsoluteTimeRange;
- logGroupNames?: string[];
- region: string;
- };
- export class CloudWatchLanguageProvider extends LanguageProvider {
- started = false;
- declare initialRange: AbsoluteTimeRange;
- datasource: CloudWatchDatasource;
- constructor(datasource: CloudWatchDatasource, initialValues?: any) {
- super();
- this.datasource = datasource;
- Object.assign(this, initialValues);
- }
- // Strip syntax chars
- cleanText = (s: string) => s.replace(/[()]/g, '').trim();
- getSyntax(): Grammar {
- return syntax;
- }
- request = (url: string, params?: any): Promise<TSDBResponse> => {
- return lastValueFrom(this.datasource.awsRequest(url, params));
- };
- start = () => {
- if (!this.startTask) {
- this.startTask = Promise.resolve().then(() => {
- this.started = true;
- return [];
- });
- }
- return this.startTask;
- };
- isStatsQuery(query: string): boolean {
- const grammar = this.getSyntax();
- const tokens = Prism.tokenize(query, grammar) ?? [];
- return !!tokens.find(
- (token) =>
- typeof token !== 'string' &&
- token.content.toString().toLowerCase() === 'stats' &&
- token.type === 'query-command'
- );
- }
- /**
- * Return suggestions based on input that can be then plugged into a typeahead dropdown.
- * Keep this DOM-free for testing
- * @param input
- * @param context Is optional in types but is required in case we are doing getLabelCompletionItems
- * @param context.absoluteRange Required in case we are doing getLabelCompletionItems
- * @param context.history Optional used only in getEmptyCompletionItems
- */
- async provideCompletionItems(input: TypeaheadInput, context?: TypeaheadContext): Promise<TypeaheadOutput> {
- const { value } = input;
- // Get tokens
- const tokens = value?.data.get('tokens');
- if (!tokens || !tokens.length) {
- return { suggestions: [] };
- }
- const curToken: Token = tokens.filter(
- (token: any) =>
- token.offsets.start <= value!.selection?.start?.offset && token.offsets.end >= value!.selection?.start?.offset
- )[0];
- const isFirstToken = !curToken.prev;
- const prevToken = prevNonWhitespaceToken(curToken);
- const isCommandStart = isFirstToken || (!isFirstToken && prevToken?.types.includes('command-separator'));
- if (isCommandStart) {
- return this.getCommandCompletionItems();
- }
- if (isInsideFunctionParenthesis(curToken)) {
- return await this.getFieldCompletionItems(context?.logGroupNames ?? [], context?.region || 'default');
- }
- if (isAfterKeyword('by', curToken)) {
- return this.handleKeyword(context);
- }
- if (prevToken?.types.includes('comparison-operator')) {
- return this.handleComparison(context);
- }
- const commandToken = previousCommandToken(curToken);
- if (commandToken) {
- return await this.handleCommand(commandToken, curToken, context);
- }
- return {
- suggestions: [],
- };
- }
- private fetchedFieldsCache:
- | {
- time: number;
- logGroups: string[];
- fields: string[];
- }
- | undefined;
- private fetchFields = async (logGroups: string[], region: string): Promise<string[]> => {
- if (
- this.fetchedFieldsCache &&
- Date.now() - this.fetchedFieldsCache.time < 30 * 1000 &&
- sortedUniq(this.fetchedFieldsCache.logGroups).join('|') === sortedUniq(logGroups).join('|')
- ) {
- return this.fetchedFieldsCache.fields;
- }
- const results = await Promise.all(
- logGroups.map((logGroup) => this.datasource.getLogGroupFields({ logGroupName: logGroup, region }))
- );
- const fields = [
- ...new Set<string>(
- results.reduce((acc: string[], cur) => acc.concat(cur.logGroupFields?.map((f) => f.name) as string[]), [])
- ).values(),
- ];
- this.fetchedFieldsCache = {
- time: Date.now(),
- logGroups,
- fields,
- };
- return fields;
- };
- private handleKeyword = async (context?: TypeaheadContext): Promise<TypeaheadOutput> => {
- const suggs = await this.getFieldCompletionItems(context?.logGroupNames ?? [], context?.region || 'default');
- const functionSuggestions: CompletionItemGroup[] = [
- {
- searchFunctionType: SearchFunctionType.Prefix,
- label: 'Functions',
- items: STRING_FUNCTIONS.concat(DATETIME_FUNCTIONS, IP_FUNCTIONS),
- },
- ];
- suggs.suggestions.push(...functionSuggestions);
- return suggs;
- };
- private handleCommand = async (
- commandToken: Token,
- curToken: Token,
- context?: TypeaheadContext
- ): Promise<TypeaheadOutput> => {
- const queryCommand = commandToken.content.toLowerCase();
- const prevToken = prevNonWhitespaceToken(curToken);
- const currentTokenIsFirstArg = prevToken === commandToken;
- if (queryCommand === 'sort') {
- return this.handleSortCommand(currentTokenIsFirstArg, curToken, context);
- }
- if (queryCommand === 'parse') {
- if (currentTokenIsFirstArg) {
- return await this.getFieldCompletionItems(context?.logGroupNames ?? [], context?.region || 'default');
- }
- }
- const currentTokenIsAfterCommandAndEmpty = isTokenType(commandToken.next, 'whitespace') && !commandToken.next?.next;
- const currentTokenIsAfterCommand =
- currentTokenIsAfterCommandAndEmpty || nextNonWhitespaceToken(commandToken) === curToken;
- const currentTokenIsComma = isTokenType(curToken, 'punctuation', ',');
- const currentTokenIsCommaOrAfterComma = currentTokenIsComma || isTokenType(prevToken, 'punctuation', ',');
- // We only show suggestions if we are after a command or after a comma which is a field separator
- if (!(currentTokenIsAfterCommand || currentTokenIsCommaOrAfterComma)) {
- return { suggestions: [] };
- }
- if (['display', 'fields'].includes(queryCommand)) {
- const typeaheadOutput = await this.getFieldCompletionItems(
- context?.logGroupNames ?? [],
- context?.region || 'default'
- );
- typeaheadOutput.suggestions.push(...this.getFieldAndFilterFunctionCompletionItems().suggestions);
- return typeaheadOutput;
- }
- if (queryCommand === 'stats') {
- const typeaheadOutput = this.getStatsAggCompletionItems();
- if (currentTokenIsComma || currentTokenIsAfterCommandAndEmpty) {
- typeaheadOutput?.suggestions.forEach((group) => {
- group.skipFilter = true;
- });
- }
- return typeaheadOutput;
- }
- if (queryCommand === 'filter' && currentTokenIsFirstArg) {
- const sugg = await this.getFieldCompletionItems(context?.logGroupNames ?? [], context?.region || 'default');
- const boolFuncs = this.getBoolFuncCompletionItems();
- sugg.suggestions.push(...boolFuncs.suggestions);
- return sugg;
- }
- return { suggestions: [] };
- };
- private async handleSortCommand(
- isFirstArgument: boolean,
- curToken: Token,
- context?: TypeaheadContext
- ): Promise<TypeaheadOutput> {
- if (isFirstArgument) {
- return await this.getFieldCompletionItems(context?.logGroupNames ?? [], context?.region || 'default');
- } else if (isTokenType(prevNonWhitespaceToken(curToken), 'field-name')) {
- // suggest sort options
- return {
- suggestions: [
- {
- searchFunctionType: SearchFunctionType.Prefix,
- label: 'Sort Order',
- items: [
- {
- label: 'asc',
- },
- { label: 'desc' },
- ],
- },
- ],
- };
- }
- return { suggestions: [] };
- }
- private handleComparison = async (context?: TypeaheadContext) => {
- const fieldsSuggestions = await this.getFieldCompletionItems(
- context?.logGroupNames ?? [],
- context?.region || 'default'
- );
- const comparisonSuggestions = this.getComparisonCompletionItems();
- fieldsSuggestions.suggestions.push(...comparisonSuggestions.suggestions);
- return fieldsSuggestions;
- };
- private getCommandCompletionItems = (): TypeaheadOutput => {
- return {
- suggestions: [{ searchFunctionType: SearchFunctionType.Prefix, label: 'Commands', items: QUERY_COMMANDS }],
- };
- };
- private getFieldAndFilterFunctionCompletionItems = (): TypeaheadOutput => {
- return {
- suggestions: [
- { searchFunctionType: SearchFunctionType.Prefix, label: 'Functions', items: FIELD_AND_FILTER_FUNCTIONS },
- ],
- };
- };
- private getStatsAggCompletionItems = (): TypeaheadOutput => {
- return {
- suggestions: [
- { searchFunctionType: SearchFunctionType.Prefix, label: 'Functions', items: AGGREGATION_FUNCTIONS_STATS },
- ],
- };
- };
- private getBoolFuncCompletionItems = (): TypeaheadOutput => {
- return {
- suggestions: [
- {
- searchFunctionType: SearchFunctionType.Prefix,
- label: 'Functions',
- items: BOOLEAN_FUNCTIONS,
- },
- ],
- };
- };
- private getComparisonCompletionItems = (): TypeaheadOutput => {
- return {
- suggestions: [
- {
- searchFunctionType: SearchFunctionType.Prefix,
- label: 'Functions',
- items: NUMERIC_OPERATORS.concat(BOOLEAN_FUNCTIONS),
- },
- ],
- };
- };
- private getFieldCompletionItems = async (logGroups: string[], region: string): Promise<TypeaheadOutput> => {
- const fields = await this.fetchFields(logGroups, region);
- return {
- suggestions: [
- {
- label: 'Fields',
- items: fields.map((field) => ({
- label: field,
- insertText: field.match(/@?[_a-zA-Z]+[_.0-9a-zA-Z]*/) ? undefined : `\`${field}\``,
- })),
- },
- ],
- };
- };
- }
- function nextNonWhitespaceToken(token: Token): Token | null {
- let curToken = token;
- while (curToken.next) {
- if (curToken.next.types.includes('whitespace')) {
- curToken = curToken.next;
- } else {
- return curToken.next;
- }
- }
- return null;
- }
- function prevNonWhitespaceToken(token: Token): Token | null {
- let curToken = token;
- while (curToken.prev) {
- if (isTokenType(curToken.prev, 'whitespace')) {
- curToken = curToken.prev;
- } else {
- return curToken.prev;
- }
- }
- return null;
- }
- function previousCommandToken(startToken: Token): Token | null {
- let thisToken = startToken;
- while (!!thisToken.prev) {
- thisToken = thisToken.prev;
- if (
- thisToken.types.includes('query-command') &&
- (!thisToken.prev || isTokenType(prevNonWhitespaceToken(thisToken), 'command-separator'))
- ) {
- return thisToken;
- }
- }
- return null;
- }
- const funcsWithFieldArgs = [
- 'avg',
- 'count',
- 'count_distinct',
- 'earliest',
- 'latest',
- 'sortsFirst',
- 'sortsLast',
- 'max',
- 'min',
- 'pct',
- 'stddev',
- 'ispresent',
- 'fromMillis',
- 'toMillis',
- 'isempty',
- 'isblank',
- 'isValidIp',
- 'isValidIpV4',
- 'isValidIpV6',
- 'isIpInSubnet',
- 'isIpv4InSubnet',
- 'isIpv6InSubnet',
- ].map((funcName) => funcName.toLowerCase());
- /**
- * Returns true if cursor is currently inside a function parenthesis for example `count(|)` or `count(@mess|)` should
- * return true.
- */
- function isInsideFunctionParenthesis(curToken: Token): boolean {
- const prevToken = prevNonWhitespaceToken(curToken);
- if (!prevToken) {
- return false;
- }
- const parenthesisToken = curToken.content === '(' ? curToken : prevToken.content === '(' ? prevToken : undefined;
- if (parenthesisToken) {
- const maybeFunctionToken = prevNonWhitespaceToken(parenthesisToken);
- if (maybeFunctionToken) {
- return (
- funcsWithFieldArgs.includes(maybeFunctionToken.content.toLowerCase()) &&
- maybeFunctionToken.types.includes('function')
- );
- }
- }
- return false;
- }
- function isAfterKeyword(keyword: string, token: Token): boolean {
- const maybeKeyword = getPreviousTokenExcluding(token, [
- 'whitespace',
- 'function',
- 'punctuation',
- 'field-name',
- 'number',
- ]);
- if (isTokenType(maybeKeyword, 'keyword', 'by')) {
- const prev = getPreviousTokenExcluding(token, ['whitespace']);
- if (prev === maybeKeyword || isTokenType(prev, 'punctuation', ',')) {
- return true;
- }
- }
- return false;
- }
- function isTokenType(token: Token | undefined | null, type: string, content?: string): boolean {
- if (!token?.types.includes(type)) {
- return false;
- }
- if (content) {
- if (token?.content.toLowerCase() !== content) {
- return false;
- }
- }
- return true;
- }
- type TokenDef = string | { type: string; value: string };
- function getPreviousTokenExcluding(token: Token, exclude: TokenDef[]): Token | undefined | null {
- let curToken = token.prev;
- main: while (curToken) {
- for (const item of exclude) {
- if (typeof item === 'string') {
- if (curToken.types.includes(item)) {
- curToken = curToken.prev;
- continue main;
- }
- } else {
- if (curToken.types.includes(item.type) && curToken.content.toLowerCase() === item.value) {
- curToken = curToken.prev;
- continue main;
- }
- }
- }
- break;
- }
- return curToken;
- }
|