diff --git a/src/platform/packages/private/kbn-esql-editor/src/esql_editor.test.tsx b/src/platform/packages/private/kbn-esql-editor/src/esql_editor.test.tsx index 20638f73d87fd..a37b91324b715 100644 --- a/src/platform/packages/private/kbn-esql-editor/src/esql_editor.test.tsx +++ b/src/platform/packages/private/kbn-esql-editor/src/esql_editor.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { BehaviorSubject } from 'rxjs'; import { IUiSettingsClient } from '@kbn/core/public'; import { mountWithIntl as mount } from '@kbn/test-jest-helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; @@ -16,7 +17,7 @@ import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { ESQLEditor } from './esql_editor'; import type { ESQLEditorProps } from './types'; import { ReactWrapper } from 'enzyme'; -import { coreMock } from '@kbn/core/server/mocks'; +import { coreMock } from '@kbn/core/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; describe('ESQLEditor', () => { @@ -25,12 +26,14 @@ describe('ESQLEditor', () => { get: (key: string) => uiConfig[key], } as IUiSettingsClient; + const corePluginMock = coreMock.createStart(); + corePluginMock.chrome.getActiveSolutionNavId$.mockReturnValue(new BehaviorSubject('oblt')); const services = { uiSettings, settings: { client: uiSettings, }, - core: coreMock.createStart(), + core: corePluginMock, data: dataPluginMock.createStartContract(), }; diff --git a/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx b/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx index 2e6b318fe3198..89a011bb30e4b 100644 --- a/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx +++ b/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx @@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { isEqual, memoize } from 'lodash'; import { CodeEditor, CodeEditorProps } from '@kbn/code-editor'; +import useObservable from 'react-use/lib/useObservable'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; import type { CoreStart } from '@kbn/core/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; @@ -126,6 +127,8 @@ export const ESQLEditor = memo(function ESQLEditor({ data, } = kibana.services; + const activeSolutionId = useObservable(core.chrome.getActiveSolutionNavId$()); + const fixedQuery = useMemo( () => fixESQLQueryWithVariables(query.esql, esqlVariables), [esqlVariables, query.esql] @@ -512,15 +515,25 @@ export const ESQLEditor = memo(function ESQLEditor({ }, getJoinIndices: kibana.services?.esql?.getJoinIndicesAutocomplete, getTimeseriesIndices: kibana.services?.esql?.getTimeseriesIndicesAutocomplete, + getEditorExtensions: async (queryString: string) => { + if (activeSolutionId) { + return ( + (await kibana.services?.esql?.getEditorExtensionsAutocomplete( + queryString, + activeSolutionId + )) ?? [] + ); + } + return []; + }, }; return callbacks; }, [ fieldsMetadata, - license, - kibana.services?.esql?.getJoinIndicesAutocomplete, - kibana.services?.esql?.getTimeseriesIndicesAutocomplete, + kibana.services?.esql, dataSourcesCache, fixedQuery, + license, memoizedSources, dataViews, core, @@ -529,10 +542,11 @@ export const ESQLEditor = memo(function ESQLEditor({ memoizedFieldsFromESQL, expressions, abortController, - indexManagementApiService, - histogramBarTarget, variablesService?.esqlVariables, variablesService?.areSuggestionsEnabled, + indexManagementApiService, + histogramBarTarget, + activeSolutionId, ]); const queryRunButtonProperties = useMemo(() => { diff --git a/src/platform/packages/private/kbn-esql-editor/src/types.ts b/src/platform/packages/private/kbn-esql-editor/src/types.ts index 48ace823e1006..a78ec36bf6b95 100644 --- a/src/platform/packages/private/kbn-esql-editor/src/types.ts +++ b/src/platform/packages/private/kbn-esql-editor/src/types.ts @@ -18,7 +18,11 @@ import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import type { ESQLControlVariable, IndicesAutocompleteResult } from '@kbn/esql-types'; +import type { + ESQLControlVariable, + IndicesAutocompleteResult, + RecommendedQuery, +} from '@kbn/esql-types'; export interface ControlsContext { /** The editor supports the creation of controls, @@ -100,6 +104,10 @@ interface ESQLVariableService { export interface EsqlPluginStartBase { getJoinIndicesAutocomplete: () => Promise; getTimeseriesIndicesAutocomplete: () => Promise; + getEditorExtensionsAutocomplete: ( + queryString: string, + activeSolutionId: string + ) => Promise; variablesService: ESQLVariableService; getLicense: () => Promise; } diff --git a/src/platform/packages/shared/kbn-esql-types/index.ts b/src/platform/packages/shared/kbn-esql-types/index.ts index a73d37ee55da7..17133b73410de 100644 --- a/src/platform/packages/shared/kbn-esql-types/index.ts +++ b/src/platform/packages/shared/kbn-esql-types/index.ts @@ -24,3 +24,8 @@ export { type IndicesAutocompleteResult, type IndexAutocompleteItem, } from './src/sources_autocomplete_types'; + +export { + type RecommendedQuery, + type ResolveIndexResponse, +} from './src/extensions_autocomplete_types'; diff --git a/src/platform/packages/shared/kbn-esql-types/src/extensions_autocomplete_types.ts b/src/platform/packages/shared/kbn-esql-types/src/extensions_autocomplete_types.ts new file mode 100644 index 0000000000000..66b627ba8e39d --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-types/src/extensions_autocomplete_types.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export interface RecommendedQuery { + // The name of the recommended query, appears in the editor as a suggestion + name: string; + // The actual ESQL query string, this is what appears in the editor when the user selects the recommendation + query: string; + // Optional description of the query, can be used to provide more context, appears at the right side of the suggestion popover + description?: string; +} + +interface ResolveIndexResponseItem { + name: string; +} + +export interface ResolveIndexResponse { + indices?: ResolveIndexResponseItem[]; + aliases?: ResolveIndexResponseItem[]; + data_streams?: ResolveIndexResponseItem[]; +} diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/__tests__/helpers.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/__tests__/helpers.ts index ff33a8dc41a01..f43755a83545b 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/__tests__/helpers.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/__tests__/helpers.ts @@ -96,6 +96,13 @@ export const timeseriesIndices: IndexAutocompleteItem[] = [ }, ]; +export const editorExtensions = [ + { + name: 'Logs Count by Host', + query: 'from logs* | STATS count(*) by host', + }, +]; + export function getCallbackMocks(): ESQLCallbacks { return { getColumnsFor: jest.fn(async ({ query } = {}) => { @@ -128,5 +135,11 @@ export function getCallbackMocks(): ESQLCallbacks { getPolicies: jest.fn(async () => policies), getJoinIndices: jest.fn(async () => ({ indices: joinIndices })), getTimeseriesIndices: jest.fn(async () => ({ indices: timeseriesIndices })), + getEditorExtensions: jest.fn(async (queryString: string) => { + if (queryString.includes('logs*')) { + return editorExtensions; + } + return []; + }), }; } diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts index e2efc402501de..cacd56b88a577 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts @@ -31,7 +31,7 @@ import { FunctionReturnType, SupportedDataType, } from '../../definitions/types'; -import { joinIndices, timeseriesIndices } from '../../__tests__/helpers'; +import { joinIndices, timeseriesIndices, editorExtensions } from '../../__tests__/helpers'; export interface Integration { name: string; @@ -304,6 +304,12 @@ export function createCustomCallbackMocks( getPolicies: jest.fn(async () => finalPolicies), getJoinIndices: jest.fn(async () => ({ indices: joinIndices })), getTimeseriesIndices: jest.fn(async () => ({ indices: timeseriesIndices })), + getEditorExtensions: jest.fn(async (queryString: string) => { + if (queryString.includes('logs*')) { + return editorExtensions; + } + return []; + }), }; } diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts index 31ef0badb9b8c..174a34a7d875e 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts @@ -28,15 +28,17 @@ import { TIME_PICKER_SUGGESTION, } from './__tests__/helpers'; import { suggest } from './autocomplete'; +import { editorExtensions } from '../__tests__/helpers'; import { getDateHistogramCompletionItem } from './commands/stats/util'; import { getSafeInsertText, TIME_SYSTEM_PARAMS, TRIGGER_SUGGESTION_COMMAND } from './factories'; import { getRecommendedQueries } from './recommended_queries/templates'; +import { mapRecommendedQueriesFromExtensions } from './recommended_queries/suggestions'; const commandDefinitions = unmodifiedCommandDefinitions.filter( ({ name, hidden }) => !hidden && name !== 'rrf' ); -const getRecommendedQueriesSuggestions = (fromCommand: string, timeField?: string) => +const getRecommendedQueriesSuggestionsFromTemplates = (fromCommand: string, timeField?: string) => getRecommendedQueries({ fromCommand, timeField, @@ -87,9 +89,13 @@ describe('autocomplete', () => { const sourceCommands = ['row', 'from', 'show']; describe('New command', () => { - const recommendedQuerySuggestions = getRecommendedQueriesSuggestions('FROM logs*', 'dateField'); + const recommendedQuerySuggestions = getRecommendedQueriesSuggestionsFromTemplates( + 'FROM logs*', + 'dateField' + ); testSuggestions('/', [ ...sourceCommands.map((name) => name.toUpperCase() + ' '), + ...mapRecommendedQueriesFromExtensions(editorExtensions), ...recommendedQuerySuggestions.map((q) => q.queryString), ]); const commands = commandDefinitions @@ -243,9 +249,13 @@ describe('autocomplete', () => { */ describe('Invoke trigger kind (all commands)', () => { // source command - let recommendedQuerySuggestions = getRecommendedQueriesSuggestions('FROM logs*', 'dateField'); + let recommendedQuerySuggestions = getRecommendedQueriesSuggestionsFromTemplates( + 'FROM logs*', + 'dateField' + ); testSuggestions('f/', [ ...sourceCommands.map((cmd) => `${cmd.toUpperCase()} `), + ...mapRecommendedQueriesFromExtensions(editorExtensions), ...recommendedQuerySuggestions.map((q) => q.queryString), ]); @@ -309,7 +319,7 @@ describe('autocomplete', () => { ]); // FROM source METADATA - recommendedQuerySuggestions = getRecommendedQueriesSuggestions('', 'dateField'); + recommendedQuerySuggestions = getRecommendedQueriesSuggestionsFromTemplates('', 'dateField'); testSuggestions('FROM index1 M/', ['METADATA ']); // FROM source METADATA field @@ -465,10 +475,14 @@ describe('autocomplete', () => { ...s, asSnippet: true, }); - let recommendedQuerySuggestions = getRecommendedQueriesSuggestions('FROM logs*', 'dateField'); + let recommendedQuerySuggestions = getRecommendedQueriesSuggestionsFromTemplates( + 'FROM logs*', + 'dateField' + ); // Source command testSuggestions('F/', [ ...['FROM ', 'ROW ', 'SHOW '].map(attachTriggerCommand), + ...mapRecommendedQueriesFromExtensions(editorExtensions), ...recommendedQuerySuggestions.map((q) => q.queryString), ]); @@ -555,7 +569,7 @@ describe('autocomplete', () => { ); }); - recommendedQuerySuggestions = getRecommendedQueriesSuggestions('', 'dateField'); + recommendedQuerySuggestions = getRecommendedQueriesSuggestionsFromTemplates('', 'dateField'); // PIPE (|) testSuggestions('FROM a /', [ @@ -604,7 +618,10 @@ describe('autocomplete', () => { ], ] ); - recommendedQuerySuggestions = getRecommendedQueriesSuggestions('index1', 'dateField'); + recommendedQuerySuggestions = getRecommendedQueriesSuggestionsFromTemplates( + 'index1', + 'dateField' + ); testSuggestions( 'FROM index1/', @@ -624,7 +641,10 @@ describe('autocomplete', () => { ] ); - recommendedQuerySuggestions = getRecommendedQueriesSuggestions('index2', 'dateField'); + recommendedQuerySuggestions = getRecommendedQueriesSuggestionsFromTemplates( + 'index2', + 'dateField' + ); testSuggestions( 'FROM index1, index2/', [ @@ -647,7 +667,10 @@ describe('autocomplete', () => { // meaning that Monaco by default will only set the replacement // range to cover "bar" and not "foo$bar". We have to make sure // we're setting it ourselves. - recommendedQuerySuggestions = getRecommendedQueriesSuggestions('foo$bar', 'dateField'); + recommendedQuerySuggestions = getRecommendedQueriesSuggestionsFromTemplates( + 'foo$bar', + 'dateField' + ); testSuggestions( 'FROM foo$bar/', [ @@ -676,7 +699,10 @@ describe('autocomplete', () => { ); // This is an identifier that matches multiple sources - recommendedQuerySuggestions = getRecommendedQueriesSuggestions('i*', 'dateField'); + recommendedQuerySuggestions = getRecommendedQueriesSuggestionsFromTemplates( + 'i*', + 'dateField' + ); testSuggestions( 'FROM i*/', [ @@ -696,7 +722,7 @@ describe('autocomplete', () => { ); }); - recommendedQuerySuggestions = getRecommendedQueriesSuggestions('', 'dateField'); + recommendedQuerySuggestions = getRecommendedQueriesSuggestionsFromTemplates('', 'dateField'); // FROM source METADATA testSuggestions('FROM index1 M/', [attachTriggerCommand('METADATA ')]); diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts index adb81c1f9e53d..0eb69dcf4476b 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts @@ -82,7 +82,11 @@ import { getLocationFromCommandOrOptionName, } from '../definitions/types'; import { comparisonFunctions } from '../definitions/all_operators'; -import { getRecommendedQueriesSuggestions } from './recommended_queries/suggestions'; +import { + getRecommendedQueriesSuggestionsFromStaticTemplates, + mapRecommendedQueriesFromExtensions, + getRecommendedQueriesTemplatesFromExtensions, +} from './recommended_queries/suggestions'; type GetFieldsMapFn = () => Promise>; type GetPoliciesFn = () => Promise; @@ -137,8 +141,19 @@ export async function suggest( resourceRetriever, innerText ); + const editorExtensions = + (await resourceRetriever?.getEditorExtensions?.(fromCommand)) ?? []; + const recommendedQueriesSuggestionsFromExtensions = + mapRecommendedQueriesFromExtensions(editorExtensions); + + const recommendedQueriesSuggestionsFromStaticTemplates = + await getRecommendedQueriesSuggestionsFromStaticTemplates( + getFieldsByTypeEmptyState, + fromCommand + ); recommendedQueriesSuggestions.push( - ...(await getRecommendedQueriesSuggestions(getFieldsByTypeEmptyState, fromCommand)) + ...recommendedQueriesSuggestionsFromExtensions, + ...recommendedQueriesSuggestionsFromStaticTemplates ); } const sourceCommandsSuggestions = suggestions.filter(isSourceCommand); @@ -336,6 +351,18 @@ async function getSuggestionsWithinCommandExpression( }); } + // Function returning suggestions from static templates and editor extensions + const getRecommendedQueries = async (queryString: string, prefix: string = '') => { + const editorExtensions = (await callbacks?.getEditorExtensions?.(queryString)) ?? []; + const recommendedQueriesFromExtensions = + getRecommendedQueriesTemplatesFromExtensions(editorExtensions); + + const recommendedQueriesFromTemplates = + await getRecommendedQueriesSuggestionsFromStaticTemplates(getColumnsByType, prefix); + + return [...recommendedQueriesFromExtensions, ...recommendedQueriesFromTemplates]; + }; + return commandDef.suggest({ innerText, command: astContext.command, @@ -360,8 +387,8 @@ async function getSuggestionsWithinCommandExpression( getPreferences, definition: commandDef, getSources, - getRecommendedQueriesSuggestions: (prefix) => - getRecommendedQueriesSuggestions(getColumnsByType, prefix), + getRecommendedQueriesSuggestions: (queryString, prefix) => + getRecommendedQueries(queryString, prefix), getSourcesFromQuery: (type) => getSourcesFromCommands(commands, type), previousCommands: commands, callbacks, diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/from/index.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/from/index.ts index ce08803e214e4..a12c10fdce573 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/from/index.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/from/index.ts @@ -50,7 +50,7 @@ export async function suggest({ suggestions.push(metadataSuggestion); suggestions.push(commaCompleteItem); suggestions.push(pipeCompleteItem); - suggestions.push(...(await getRecommendedQueriesSuggestions())); + suggestions.push(...(await getRecommendedQueriesSuggestions(innerText))); } // FROM something MET/ else if (indexes.length > 0 && /^FROM\s+\S+\s+/i.test(innerText) && metadataOverlap) { @@ -62,7 +62,7 @@ export async function suggest({ else if (indexes.length) { const sources = await getSources(); - const recommendedQuerySuggestions = await getRecommendedQueriesSuggestions(); + const recommendedQuerySuggestions = await getRecommendedQueriesSuggestions(innerText); const additionalSuggestions = await additionalSourcesSuggestions( innerText, sources, diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/suggestions.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/suggestions.ts index 2674ed86d90ce..44e7e047758e5 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/suggestions.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/suggestions.ts @@ -6,11 +6,11 @@ * your election, the "Elastic License 2.0", the "GNU Affero General Public * License v3.0 only", or the "Server Side Public License, v 1". */ - +import type { RecommendedQuery } from '@kbn/esql-types'; import type { SuggestionRawDefinition, GetColumnsByTypeFn } from '../types'; import { getRecommendedQueries } from './templates'; -export const getRecommendedQueriesSuggestions = async ( +export const getRecommendedQueriesSuggestionsFromStaticTemplates = async ( getFieldsByType: GetColumnsByTypeFn, fromCommand: string = '' ): Promise => { @@ -38,3 +38,56 @@ export const getRecommendedQueriesSuggestions = async ( return suggestions; }; + +/** + * This function maps the recommended queries from the extensions to the autocomplete suggestions. + * @param recommendedQueriesExtensions, the recommended queries extensions to map + * @returns SuggestionRawDefinition[], the mapped suggestions + */ +export const mapRecommendedQueriesFromExtensions = ( + recommendedQueriesExtensions: RecommendedQuery[] +): SuggestionRawDefinition[] => { + const suggestions: SuggestionRawDefinition[] = recommendedQueriesExtensions.map((extension) => { + return { + label: extension.name, + text: extension.query, + detail: extension.description ?? '', + kind: 'Issue', + sortText: 'D', + }; + }); + + return suggestions; +}; + +/** + * This function extracts the templates from the recommended queries extensions. + * The templates are the recommended queries without the source command (FROM). + * This is useful for showing the templates in the autocomplete suggestions when the users have already typed the FROM command. + * @param recommendedQueriesExtensions, the recommended queries extensions to extract the templates from + * @returns SuggestionRawDefinition[], the templates extracted from the recommended queries extensions + */ +export const getRecommendedQueriesTemplatesFromExtensions = ( + recommendedQueriesExtensions: RecommendedQuery[] +): SuggestionRawDefinition[] => { + if (!recommendedQueriesExtensions || !recommendedQueriesExtensions.length) { + return []; + } + + // the templates are the recommended queries without the source command (FROM) + const recommendedQueriesTemplates: SuggestionRawDefinition[] = recommendedQueriesExtensions.map( + (recommendedQuery) => { + const queryParts = recommendedQuery.query.split('|'); + // remove the first part (the FROM command) + return { + label: recommendedQuery.name, + text: `|${queryParts.slice(1).join('|')}`, + detail: recommendedQuery.description ?? '', + kind: 'Issue', + sortText: 'D', + }; + } + ); + + return recommendedQueriesTemplates; +}; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/types.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/types.ts index 78db4231d56a5..3a32e31e4cfda 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/types.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/types.ts @@ -367,7 +367,10 @@ export interface CommandSuggestParams { * Generate a list of recommended queries * @returns */ - getRecommendedQueriesSuggestions: (prefix?: string) => Promise; + getRecommendedQueriesSuggestions: ( + queryString: string, + prefix?: string + ) => Promise; /** * The AST for the query behind the cursor. */ diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/types.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/types.ts index 0755485fcb295..f0ad219888afe 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/types.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/types.ts @@ -6,7 +6,7 @@ * your election, the "Elastic License 2.0", the "GNU Affero General Public * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { ESQLControlVariable, IndexAutocompleteItem } from '@kbn/esql-types'; +import type { ESQLControlVariable, IndexAutocompleteItem, RecommendedQuery } from '@kbn/esql-types'; import type { ESQLFieldWithMetadata } from '../validation/types'; /** @internal **/ @@ -50,6 +50,7 @@ export interface ESQLCallbacks { canSuggestVariables?: () => boolean; getJoinIndices?: () => Promise<{ indices: IndexAutocompleteItem[] }>; getTimeseriesIndices?: () => Promise<{ indices: IndexAutocompleteItem[] }>; + getEditorExtensions?: (queryString: string) => Promise; } export type ReasonTypes = 'missingCommand' | 'unsupportedFunction' | 'unknownFunction'; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts index 24e8d0c8e77bb..6dc60381927f1 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts @@ -117,6 +117,7 @@ export const ignoreErrorsMap: Record = { canSuggestVariables: [], getJoinIndices: [], getTimeseriesIndices: [], + getEditorExtensions: [], }; /** diff --git a/src/platform/plugins/shared/esql/public/plugin.ts b/src/platform/plugins/shared/esql/public/plugin.ts index 8871f08fdaa02..7e5f6c83087b6 100755 --- a/src/platform/plugins/shared/esql/public/plugin.ts +++ b/src/platform/plugins/shared/esql/public/plugin.ts @@ -18,6 +18,8 @@ import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/publ import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; import type { IndicesAutocompleteResult } from '@kbn/esql-types'; import { Storage } from '@kbn/kibana-utils-plugin/public'; +import type { KibanaProject as SolutionId } from '@kbn/projects-solutions-groups'; + import { esqlControlTrigger, ESQL_CONTROL_TRIGGER, @@ -28,7 +30,7 @@ import { } from './triggers/update_esql_query/update_esql_query_trigger'; import { ACTION_UPDATE_ESQL_QUERY, ACTION_CREATE_ESQL_CONTROL } from './triggers/constants'; import { setKibanaServices } from './kibana_services'; -import { cacheNonParametrizedAsyncFunction } from './util/cache'; +import { cacheNonParametrizedAsyncFunction, cacheParametrizedAsyncFunction } from './util/cache'; import { EsqlVariablesService } from './variables_service'; interface EsqlPluginSetupDependencies { @@ -125,9 +127,28 @@ export class EsqlPlugin implements Plugin<{}, EsqlPluginStart> { 1000 * 15 // Refresh the cache in the background only if 15 seconds passed since the last call ); + const getEditorExtensionsAutocomplete = async ( + queryString: string, + activeSolutionId: SolutionId + ) => { + const result = await core.http.get( + `/internal/esql_registry/extensions/${activeSolutionId}/${queryString}` + ); + return result; + }; + + // Create a cached version of getEditorExtensionsAutocomplete + const cachedGetEditorExtensionsAutocomplete = cacheParametrizedAsyncFunction( + getEditorExtensionsAutocomplete, + (queryString, activeSolutionId) => `${queryString}-${activeSolutionId}`, + 1000 * 60 * 5, // Keep the value in cache for 5 minutes + 1000 * 15 // Refresh the cache in the background only if 15 seconds passed since the last call + ); + const start = { getJoinIndicesAutocomplete, getTimeseriesIndicesAutocomplete, + getEditorExtensionsAutocomplete: cachedGetEditorExtensionsAutocomplete, variablesService, getLicense: async () => await licensing?.getLicense(), }; diff --git a/src/platform/plugins/shared/esql/public/util/cache.test.ts b/src/platform/plugins/shared/esql/public/util/cache.test.ts index 423519fb8de17..6e0e588577c6a 100644 --- a/src/platform/plugins/shared/esql/public/util/cache.test.ts +++ b/src/platform/plugins/shared/esql/public/util/cache.test.ts @@ -7,117 +7,242 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { cacheNonParametrizedAsyncFunction } from './cache'; +import { cacheNonParametrizedAsyncFunction, cacheParametrizedAsyncFunction } from './cache'; -it('returns the value returned by the original function', async () => { - const fn = jest.fn().mockResolvedValue('value'); - const cached = cacheNonParametrizedAsyncFunction(fn); - const value = await cached(); +describe('cacheNonParametrizedAsyncFunction', () => { + it('returns the value returned by the original function', async () => { + const fn = jest.fn().mockResolvedValue('value'); + const cached = cacheNonParametrizedAsyncFunction(fn); + const value = await cached(); - expect(value).toBe('value'); -}); + expect(value).toBe('value'); + }); -it('immediate consecutive calls do not call the original function', async () => { - const fn = jest.fn().mockResolvedValue('value'); - const cached = cacheNonParametrizedAsyncFunction(fn); - const value1 = await cached(); + it('immediate consecutive calls do not call the original function', async () => { + const fn = jest.fn().mockResolvedValue('value'); + const cached = cacheNonParametrizedAsyncFunction(fn); + const value1 = await cached(); - expect(fn.mock.calls.length).toBe(1); + expect(fn.mock.calls.length).toBe(1); - const value2 = await cached(); + const value2 = await cached(); - expect(fn.mock.calls.length).toBe(1); + expect(fn.mock.calls.length).toBe(1); - const value3 = await cached(); + const value3 = await cached(); - expect(fn.mock.calls.length).toBe(1); + expect(fn.mock.calls.length).toBe(1); - expect(value1).toBe('value'); - expect(value2).toBe('value'); - expect(value3).toBe('value'); -}); + expect(value1).toBe('value'); + expect(value2).toBe('value'); + expect(value3).toBe('value'); + }); -it('immediate consecutive synchronous calls do not call the original function', async () => { - const fn = jest.fn().mockResolvedValue('value'); - const cached = cacheNonParametrizedAsyncFunction(fn); - const value1 = cached(); - const value2 = cached(); - const value3 = cached(); - - expect(fn.mock.calls.length).toBe(1); - expect(await value1).toBe('value'); - expect(await value2).toBe('value'); - expect(await value3).toBe('value'); - expect(fn.mock.calls.length).toBe(1); -}); + it('immediate consecutive synchronous calls do not call the original function', async () => { + const fn = jest.fn().mockResolvedValue('value'); + const cached = cacheNonParametrizedAsyncFunction(fn); + const value1 = cached(); + const value2 = cached(); + const value3 = cached(); -it('does not call original function if cached value is fresh enough', async () => { - let time = 1; - let value = 'value1'; - const now = jest.fn(() => time); - const fn = jest.fn(async () => value); - const cached = cacheNonParametrizedAsyncFunction(fn, 100, 20, now); + expect(fn.mock.calls.length).toBe(1); + expect(await value1).toBe('value'); + expect(await value2).toBe('value'); + expect(await value3).toBe('value'); + expect(fn.mock.calls.length).toBe(1); + }); - const value1 = await cached(); + it('does not call original function if cached value is fresh enough', async () => { + let time = 1; + let value = 'value1'; + const now = jest.fn(() => time); + const fn = jest.fn(async () => value); + const cached = cacheNonParametrizedAsyncFunction(fn, 100, 20, now); - expect(fn.mock.calls.length).toBe(1); - expect(value1).toBe('value1'); + const value1 = await cached(); - time = 10; - value = 'value2'; + expect(fn.mock.calls.length).toBe(1); + expect(value1).toBe('value1'); - const value2 = await cached(); + time = 10; + value = 'value2'; - expect(fn.mock.calls.length).toBe(1); - expect(value2).toBe('value1'); -}); + const value2 = await cached(); -it('immediately returns cached value, but calls original function when sufficient time passed', async () => { - let time = 1; - let value = 'value1'; - const now = jest.fn(() => time); - const fn = jest.fn(async () => value); - const cached = cacheNonParametrizedAsyncFunction(fn, 100, 20, now); + expect(fn.mock.calls.length).toBe(1); + expect(value2).toBe('value1'); + }); - const value1 = await cached(); + it('immediately returns cached value, but calls original function when sufficient time passed', async () => { + let time = 1; + let value = 'value1'; + const now = jest.fn(() => time); + const fn = jest.fn(async () => value); + const cached = cacheNonParametrizedAsyncFunction(fn, 100, 20, now); - expect(fn.mock.calls.length).toBe(1); - expect(value1).toBe('value1'); + const value1 = await cached(); - time = 30; - value = 'value2'; + expect(fn.mock.calls.length).toBe(1); + expect(value1).toBe('value1'); - const value2 = await cached(); + time = 30; + value = 'value2'; - expect(fn.mock.calls.length).toBe(2); - expect(value2).toBe('value1'); + const value2 = await cached(); - time = 50; - value = 'value3'; + expect(fn.mock.calls.length).toBe(2); + expect(value2).toBe('value1'); - const value3 = await cached(); + time = 50; + value = 'value3'; - expect(fn.mock.calls.length).toBe(2); - expect(value3).toBe('value2'); -}); + const value3 = await cached(); + + expect(fn.mock.calls.length).toBe(2); + expect(value3).toBe('value2'); + }); -it('blocks and refreshes the value when cache expires', async () => { - let time = 1; - let value = 'value1'; - const now = jest.fn(() => time); - const fn = jest.fn(async () => value); - const cached = cacheNonParametrizedAsyncFunction(fn, 100, 20, now); + it('blocks and refreshes the value when cache expires', async () => { + let time = 1; + let value = 'value1'; + const now = jest.fn(() => time); + const fn = jest.fn(async () => value); + const cached = cacheNonParametrizedAsyncFunction(fn, 100, 20, now); - const value1 = await cached(); + const value1 = await cached(); - expect(fn.mock.calls.length).toBe(1); - expect(value1).toBe('value1'); + expect(fn.mock.calls.length).toBe(1); + expect(value1).toBe('value1'); - time = 130; - value = 'value2'; + time = 130; + value = 'value2'; - const value2 = await cached(); + const value2 = await cached(); + + expect(fn.mock.calls.length).toBe(2); + expect(value2).toBe('value2'); + }); +}); - expect(fn.mock.calls.length).toBe(2); - expect(value2).toBe('value2'); +describe('cacheParametrizedAsyncFunction', () => { + let mockNow: jest.Mock; // Mock function for Date.now + + beforeEach(() => { + mockNow = jest.fn(); + mockNow.mockReturnValue(0); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); // Clear any pending timers + jest.useRealTimers(); // Restore real timers + }); + + // Helper to advance time in tests + const advanceTime = (ms: number) => { + mockNow.mockReturnValue(mockNow() + ms); + jest.advanceTimersByTime(ms); + }; + + it('should call the function and cache the result on the first call', async () => { + const fn = jest.fn().mockResolvedValue('value'); + const cachedFn = cacheParametrizedAsyncFunction(fn); + + const value = await cachedFn(); + + expect(value).toBe('value'); + }); + + it('should return the cached value for subsequent calls with the same arguments within maxCacheDuration', async () => { + const fn = jest.fn().mockResolvedValue('first_value'); + const cachedFn = cacheParametrizedAsyncFunction(fn, undefined, undefined, undefined, mockNow); + + await cachedFn('argA', 1); // First call, caches 'first_value' + + fn.mockResolvedValueOnce('second_value'); // This should not be called + const result = await cachedFn('argA', 1); // Second call, should return cached + + expect(result).toBe('first_value'); + expect(fn).toHaveBeenCalledTimes(1); // Still only one call to original function + }); + + it('should call the function again if maxCacheDuration expires', async () => { + const fn = jest.fn().mockResolvedValue('first_value'); + const maxCacheDuration = 1000; // 1 second + const cachedFn = cacheParametrizedAsyncFunction( + fn, + undefined, + maxCacheDuration, + undefined, + mockNow + ); + + await cachedFn('argB', 2); + expect(fn).toHaveBeenCalledTimes(1); + + advanceTime(maxCacheDuration + 1); // Advance time just past maxCacheDuration + + fn.mockResolvedValueOnce('expired_value'); + const result = await cachedFn('argB', 2); + + expect(result).toBe('expired_value'); + expect(fn).toHaveBeenCalledTimes(2); // Should have called again + }); + + it('should use the provided getKey function for caching', async () => { + const fn = jest.fn().mockResolvedValue('value_for_key'); + const customGetKey = (a: string, b: number) => `${a}-${b * 2}`; + const cachedFn = cacheParametrizedAsyncFunction( + fn, + customGetKey, + undefined, + undefined, + mockNow + ); + + await cachedFn('keyPartA', 5); + expect(fn).toHaveBeenCalledWith('keyPartA', 5); + + fn.mockResolvedValueOnce('this_should_not_be_called'); + // Call with arguments that produce the same key + const result = await cachedFn('keyPartA', 5); + + expect(result).toBe('value_for_key'); + expect(fn).toHaveBeenCalledTimes(1); // Still one call because key is the same + }); + + it('should trigger a background refresh if refreshAfter expires', async () => { + const fn = jest.fn().mockResolvedValue('initial_value'); + const maxCacheDuration = 1000 * 60 * 5; // 5 minutes + const refreshAfter = 1000 * 15; // 15 seconds + + const cachedFn = cacheParametrizedAsyncFunction( + fn, + undefined, + maxCacheDuration, + refreshAfter, + mockNow + ); + + const firstResult = await cachedFn('argC', 3); // Caches 'initial_value' + expect(firstResult).toBe('initial_value'); + expect(fn).toHaveBeenCalledTimes(1); + + advanceTime(refreshAfter + 1); // Advance time just past refreshAfter + + // Call should return the *old* value immediately, but trigger a background refresh + fn.mockResolvedValueOnce('refreshed_value'); + const secondResult = await cachedFn('argC', 3); + expect(secondResult).toBe('initial_value'); // Should still be the initial value + + // Allow the background refresh promise to resolve + await Promise.resolve(); // Resolves the `.then` callback + await Promise.resolve(); // Resolves the async function itself + jest.runAllTimers(); + + // Now, a subsequent call should reflect the refreshed value + const thirdResult = await cachedFn('argC', 3); + expect(thirdResult).toBe('refreshed_value'); + expect(fn).toHaveBeenCalledTimes(2); // Now fn has been called for refresh + }); }); diff --git a/src/platform/plugins/shared/esql/public/util/cache.ts b/src/platform/plugins/shared/esql/public/util/cache.ts index 10d1ae2d84f49..9b45b5dd44530 100644 --- a/src/platform/plugins/shared/esql/public/util/cache.ts +++ b/src/platform/plugins/shared/esql/public/util/cache.ts @@ -54,3 +54,55 @@ export const cacheNonParametrizedAsyncFunction = ( return value; }; }; + +interface CacheEntry { + value: Promise; + lastCallTime: number; +} + +/** + * Caches the result of an async function based on its arguments. + * + * @param fn Function to call to get the value. + * @param getKey Function to generate a unique cache key from the arguments. + * @param maxCacheDuration For how long to keep a value in the cache, + * in milliseconds. Defaults to 5 minutes. + * @param refreshAfter Minimum time between cache refreshes, in milliseconds. + * Defaults to 15 seconds. + * @param now Function which returns the current time in milliseconds, defaults to `Date.now`. + * @returns A function which returns the cached value. + */ +export const cacheParametrizedAsyncFunction = ( + fn: (...args: Args) => Promise, + getKey: (...args: Args) => string = (...args) => JSON.stringify(args), + maxCacheDuration: number = 1000 * 60 * 5, + refreshAfter: number = 1000 * 15, + now: () => number = Date.now +) => { + const cache = new Map>(); + + return (...args: Args): Promise => { + const key = getKey(...args); + const time = now(); + let entry = cache.get(key); + + // If no entry or cache expired + if (!entry || time - entry.lastCallTime > maxCacheDuration) { + const newValue = fn(...args); + entry = { value: newValue, lastCallTime: time }; + cache.set(key, entry); + return newValue; + } + + // If entry exists, but needs refresh + if (time - entry.lastCallTime > refreshAfter) { + // Refresh in the background + Promise.resolve().then(async () => { + const refreshedValue = await fn(...args); + cache.set(key, { value: Promise.resolve(refreshedValue), lastCallTime: now() }); + }); + } + + return entry.value; + }; +}; diff --git a/src/platform/plugins/shared/esql/server/README.md b/src/platform/plugins/shared/esql/server/README.md new file mode 100644 index 0000000000000..20ae8ab60b438 --- /dev/null +++ b/src/platform/plugins/shared/esql/server/README.md @@ -0,0 +1,74 @@ +# ES|QL Server Plugin + +This directory contains the server-side components and API routes for the ES|QL plugin. It provides backend services that power various functionalities within the Kibana ES|QL editor and related features. + +--- + +## Registered API Routes + +The ES|QL server exposes the following internal API routes: + +* **`/internal/esql/autocomplete/join/indices`**: Used by the ES|QL editor to retrieve a list of indices suitable for **`JOIN`** autocompletion. +* **`/internal/esql/autocomplete/timeseries/indices`**: Provides index suggestions specifically for **time-series analysis** contexts within the ES|QL editor. +* **`/internal/esql_registry/extensions/{query}`**: This is the primary endpoint for fetching **registered ES|QL extensions**, which enhance the editor's capabilities by providing contextual suggestions. + +--- + +## ES|QL Extensions Registry + +The **ES|QL Extensions Registry** is a powerful mechanism that allows other Kibana plugins to seamlessly integrate and provide context-aware suggestions and capabilities directly within the ES|QL editor. By registering extensions, plugins can enhance the user's query writing experience. + +Currently, we support the following type of extension: + +* **Recommended Queries**: These queries are suggested to users in the ES|QL editor, particularly after the **`FROM `** command. They guide users by offering relevant starting points or common analytical patterns for their selected data source. + + **Note:** The registry intelligently handles both **exact index pattern matches** (e.g., "logs-2023-10-01") and **wildcard patterns** (e.g., "logs*"). This ensures users receive comprehensive and contextually appropriate suggestions, whether they specify a precise index or a broad pattern. For instance, a recommended query registered for `logs*` will be suggested if the user's query uses `FROM logs-2024-01-15`. + + **Important:** Extensions registered through this mechanism are **solution-specific**. They are categorized by solution (e.g., 'es', 'oblt', 'security', 'chat') and are only visible when working within the context of that specific solution. Extensions are not displayed in general or non-solution-based Kibana instances. + +--- + +## Registering Extensions + +Plugins can easily register their desired ES|QL extensions by adding a dependency on the ES|QL plugin's server-side setup. + +Here's an example of how to register `recommendedQueries`: + +- Add **esql** as a dependency on your kibana.jsonc file + +- **Add `esql` plugin dependency** in your plugin's `setup` method: + + ```typescript + import { PluginSetup as ESQLSetup } from '@kbn/esql/server'; + import { CoreSetup } from '@kbn/core/server'; // Assuming CoreSetup is needed + + interface SetupDeps { + esql: ESQLSetup; + // ... other dependencies + } + + // Inside your plugin's `Plugin` class + public setup(core: CoreSetup, { esql }: SetupDeps) { + // Register your array of recommended queries + const esqlExtensionsRegistry = esql.getExtensionsRegistry(); + esqlExtensionsRegistry.setRecommendedQueries( + [ + { + name: 'Logs count by log level', + query: 'from logs* | STATS count(*) by log_level', + }, + { + name: 'Apache logs counts', + query: 'from logs-apache_error | STATS count(*)', + }, + { + name: 'Another index, not logs', + query: 'from movies | STATS count(*)', + }, + ], + 'oblt' + ); + return {}; + } + +--- \ No newline at end of file diff --git a/src/platform/plugins/shared/esql/server/extensions_registry/index.test.ts b/src/platform/plugins/shared/esql/server/extensions_registry/index.test.ts new file mode 100644 index 0000000000000..1ba3e5b286101 --- /dev/null +++ b/src/platform/plugins/shared/esql/server/extensions_registry/index.test.ts @@ -0,0 +1,229 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import type { RecommendedQuery, ResolveIndexResponse } from '@kbn/esql-types'; +import type { KibanaProject as SolutionId } from '@kbn/projects-solutions-groups'; +import { ESQLExtensionsRegistry } from '.'; + +describe('ESQLExtensionsRegistry', () => { + let registry: ESQLExtensionsRegistry; + let availableDatasources: ResolveIndexResponse; + + beforeEach(() => { + registry = new ESQLExtensionsRegistry(); + }); + + // --- setRecommendedQueries tests --- + describe('setRecommendedQueries', () => { + beforeEach(() => { + availableDatasources = { + indices: [ + { name: 'logs-2023' }, + { name: 'metrics-*' }, + { name: 'my_index' }, + { name: 'another_index' }, + ], + }; + }); + it('should add recommended queries to the registry', () => { + const solutionId: SolutionId = 'oblt'; + const queries: RecommendedQuery[] = [ + { name: 'Query 1', query: 'FROM logs-2023 | STATS count()' }, + { name: 'Query 2', query: 'FROM metrics-* | STATS avg(value)' }, + ]; + + registry.setRecommendedQueries(queries, solutionId); + const retrievedQueriesForLogs = registry.getRecommendedQueries( + 'FROM logs-2023', + availableDatasources, + solutionId + ); + expect(retrievedQueriesForLogs).toEqual([queries[0]]); + + const retrievedQueriesForMetrics = registry.getRecommendedQueries( + 'FROM metrics-*', + availableDatasources, + solutionId + ); + expect(retrievedQueriesForMetrics).toEqual([queries[1]]); + }); + + it('should skip malformed recommended queries (missing name or query)', () => { + const solutionId: SolutionId = 'oblt'; + const queries: RecommendedQuery[] = [ + { name: 'Valid Query', query: 'FROM my_index | STATS count()' }, + { name: 'Missing Query' } as RecommendedQuery, // Malformed + { query: 'FROM another_index | STATS sum()' } as RecommendedQuery, // Malformed + ]; + + registry.setRecommendedQueries(queries, solutionId); + + // Ensure only the valid query was effectively added + const retrievedQueries = registry.getRecommendedQueries( + 'FROM my_index', + availableDatasources, + solutionId + ); + expect(retrievedQueries).toEqual([ + { name: 'Valid Query', query: 'FROM my_index | STATS count()' }, + ]); + }); + + it('should skip queries if no index pattern is found from the query string', () => { + const solutionId: SolutionId = 'es'; + const queries: RecommendedQuery[] = [ + { name: 'Valid Query', query: 'FROM my_index | STATS count()' }, + { name: 'No Pattern Query', query: 'STATS count()' }, // No index pattern, malformed + ]; + + registry.setRecommendedQueries(queries, solutionId); + + // Verify only the query with a pattern was added + const retrievedQueries = registry.getRecommendedQueries( + 'FROM my_index', + availableDatasources, + solutionId + ); + expect(retrievedQueries).toEqual([ + { name: 'Valid Query', query: 'FROM my_index | STATS count()' }, + ]); + + const retrievedQueriesForNoPattern = registry.getRecommendedQueries( + 'STATS count()', + availableDatasources, + solutionId + ); + expect(retrievedQueriesForNoPattern).toEqual([]); + }); + + it('should not add duplicate recommended queries for the same registryId and query', () => { + const solutionId: SolutionId = 'es'; + const queryA: RecommendedQuery = { + name: 'Query A', + query: 'FROM another_index | STATS count()', + }; + const queries: RecommendedQuery[] = [queryA, queryA]; // Duplicate entry + + registry.setRecommendedQueries(queries, solutionId); + const retrievedQueries = registry.getRecommendedQueries( + 'FROM another_index', + availableDatasources, + solutionId + ); + expect(retrievedQueries).toEqual([queryA]); + }); + + it('should handle different solution IDs correctly', () => { + const query1: RecommendedQuery = { name: 'Q1', query: 'FROM logs* | STATS count()' }; + const query2: RecommendedQuery = { name: 'Q2', query: 'FROM logs* | STATS sum(value)' }; // Same index pattern, different solution + + registry.setRecommendedQueries([query1], 'oblt'); + registry.setRecommendedQueries([query2], 'security'); + + // Retrieve for oblst + const queriesOblt = registry.getRecommendedQueries( + 'FROM logs*', + availableDatasources, + 'oblt' + ); + expect(queriesOblt).toEqual([query1]); + + // Retrieve for security + const queriesSecurity = registry.getRecommendedQueries( + 'FROM logs*', + availableDatasources, + 'security' + ); + expect(queriesSecurity).toEqual([query2]); + }); + }); + + // --- getRecommendedQueries tests --- + + describe('getRecommendedQueries', () => { + beforeEach(() => { + availableDatasources = { + indices: [ + { name: 'logs-2023' }, + { name: 'logs-2024' }, + { name: 'metrics' }, + { name: 'other_index' }, + ], + data_streams: [], + aliases: [], + }; + + // Setup some initial queries in the registry + const registeredQueries: RecommendedQuery[] = [ + { name: 'Logs Query', query: 'FROM logs-2023 | STATS count()' }, + { name: 'Metrics Query', query: 'FROM metrics | STATS max(bytes)' }, + { name: 'Wildcard Logs Query', query: 'FROM logs-* | LIMIT 5' }, + { name: 'Other Solution Query', query: 'FROM other_index | LIMIT 1' }, + ]; + + registry.setRecommendedQueries( + [registeredQueries[0], registeredQueries[1], registeredQueries[2]], + 'oblt' + ); + // Register a query for a different solution to test filtering + registry.setRecommendedQueries([registeredQueries[3]], 'es'); + }); + + it('should return an empty array if checkSourceExistence returns false', () => { + const result = registry.getRecommendedQueries( + 'FROM non_existent_index', + availableDatasources, + 'oblt' + ); + expect(result).toEqual([]); + }); + + it('should return queries matching the exact index pattern from the query string', () => { + const result = registry.getRecommendedQueries('FROM logs-2023', availableDatasources, 'oblt'); + expect(result).toEqual([ + { name: 'Logs Query', query: 'FROM logs-2023 | STATS count()' }, + { name: 'Wildcard Logs Query', query: 'FROM logs-* | LIMIT 5' }, + ]); + }); + + it('should return queries matching a wildcard pattern from the query string', () => { + const result = registry.getRecommendedQueries('FROM logs-*', availableDatasources, 'oblt'); + // Expect both the concrete 'logs-2023' query and the wildcard 'logs-*' query to be returned + expect(result).toEqual([ + { name: 'Logs Query', query: 'FROM logs-2023 | STATS count()' }, + { name: 'Wildcard Logs Query', query: 'FROM logs-* | LIMIT 5' }, + ]); + }); + + it('should return queries where the registered pattern covers the concrete index in the query string', () => { + const result = registry.getRecommendedQueries('FROM logs-2024', availableDatasources, 'oblt'); + // Expect the 'logs-*' query to be returned because it covers 'logs-2024' + expect(result).toEqual([{ name: 'Wildcard Logs Query', query: 'FROM logs-* | LIMIT 5' }]); + }); + + it('should filter queries by activeSolutionId', () => { + const result = registry.getRecommendedQueries( + 'FROM other_index', + availableDatasources, + 'oblt' + ); + + expect(result).toEqual([]); // Should be empty because it's set for 'es', not 'oblt' + }); + + it('should return an empty array if no matching indices are found by findMatchingIndicesFromPattern', () => { + const result = registry.getRecommendedQueries( + 'FROM non_matching_index', + availableDatasources, + 'oblt' + ); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/platform/plugins/shared/esql/server/extensions_registry/index.ts b/src/platform/plugins/shared/esql/server/extensions_registry/index.ts new file mode 100644 index 0000000000000..b5f5923ee6a42 --- /dev/null +++ b/src/platform/plugins/shared/esql/server/extensions_registry/index.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import { uniqBy } from 'lodash'; +import type { RecommendedQuery, ResolveIndexResponse } from '@kbn/esql-types'; +import type { KibanaProject as SolutionId } from '@kbn/projects-solutions-groups'; +import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils'; +import { checkSourceExistence, findMatchingIndicesFromPattern } from './utils'; + +/** + * `ESQLExtensionsRegistry` serves as a central hub for managing and retrieving extrensions of the ES|QL editor. + * + * It allows for the registration of queries, associating them with specific index patterns and solutions. + * This registry is designed to intelligently provide relevant recommended queries + * based on the index patterns present in an active ES|QL query or available data sources. + * + * The class handles both exact index pattern matches (e.g., "logs-2023-10-01") + * and wildcard patterns (e.g., "logs*"), ensuring that users receive contextually + * appropriate suggestions for their data exploration. + */ + +export class ESQLExtensionsRegistry { + private recommendedQueries: Map = new Map(); + + setRecommendedQueries( + recommendedQueries: RecommendedQuery[], + activeSolutionId: SolutionId + ): void { + if (!Array.isArray(recommendedQueries)) { + throw new Error('Recommended queries must be an array'); + } + for (const recommendedQuery of recommendedQueries) { + if (typeof recommendedQuery.name !== 'string' || typeof recommendedQuery.query !== 'string') { + continue; // Skip if the recommended query is malformed + } + const indexPattern = getIndexPatternFromESQLQuery(recommendedQuery.query); + if (!indexPattern) { + // No index pattern found for query, possibly malformed or not ES|QL + continue; + } + + // Adding the > as separator to distinguish between solutions and index patterns + // The > is not a valid character in index names, so it won't conflict with actual index names + const registryId = `${activeSolutionId}>${indexPattern}`; + + if (this.recommendedQueries.has(registryId)) { + const existingQueries = this.recommendedQueries.get(registryId); + // check if the recommended query already exists + if (existingQueries && existingQueries.some((q) => q.query === recommendedQuery.query)) { + // If the query already exists, skip adding it again + continue; + } + // If the index pattern already exists, push the new recommended query + this.recommendedQueries.get(registryId)!.push(recommendedQuery); + } else { + // If the index pattern doesn't exist, create a new array + this.recommendedQueries.set(registryId, [recommendedQuery]); + } + } + } + + getRecommendedQueries( + queryString: string, + availableDatasources: ResolveIndexResponse, + activeSolutionId: SolutionId + ): RecommendedQuery[] { + // Validates that the index pattern extracted from the ES|QL `FROM` command + // exists within the available `sources` (indices, aliases, or data streams). + // If the specified source isn't found, no recommended queries will be returned. + const indexPattern = getIndexPatternFromESQLQuery(queryString); + if (!checkSourceExistence(availableDatasources, indexPattern)) { + return []; + } + + const recommendedQueries: RecommendedQuery[] = []; + + // Determines relevant recommended queries based on the ESQL `FROM` command's index pattern. + // This includes: + // 1. **Direct matches**: If the command uses a specific index (e.g., `logs-2023`), it retrieves queries registered for that exact index. + // 2. **Pattern coverage**: If the command uses a wildcard pattern (e.g., `logs-*`), it returns queries registered for concrete indices that match this pattern (e.g., a recommended query for `logs-2023`). + // 3. **Reverse coverage**: If the command specifies a concrete index, it also includes queries whose *registered pattern* covers that specific index (e.g., a recommended query for `logs*` would be returned for `logs-2023`). + const matchingIndices = findMatchingIndicesFromPattern(this.recommendedQueries, indexPattern); + if (matchingIndices.length > 0) { + recommendedQueries.push( + ...matchingIndices + .map((index) => { + const registryId = `${activeSolutionId}>${index}`; + return this.recommendedQueries.get(registryId) || []; + }) + .flat() + ); + } + return uniqBy(recommendedQueries, 'query'); + } +} diff --git a/src/platform/plugins/shared/esql/server/extensions_registry/utils.test.ts b/src/platform/plugins/shared/esql/server/extensions_registry/utils.test.ts new file mode 100644 index 0000000000000..67bdfc20bd021 --- /dev/null +++ b/src/platform/plugins/shared/esql/server/extensions_registry/utils.test.ts @@ -0,0 +1,260 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import type { RecommendedQuery, ResolveIndexResponse } from '@kbn/esql-types'; +import { findMatchingIndicesFromPattern, checkSourceExistence } from './utils'; + +describe('Extensions registry utils', () => { + describe('checkSourceExistence', () => { + let mockSources: ResolveIndexResponse; + + beforeEach(() => { + mockSources = { + indices: [ + { name: 'bike-hire-stations' }, + { name: 'logs-apache_error' }, + { name: 'logs-aws_s3' }, + { name: 'logstash-0' }, + { name: 'logstash-1' }, + { name: 'movies' }, + { name: 'finance-data-2024' }, + ], + aliases: [ + { name: '.alerts-default.alerts-default' }, + { name: '.siem-signals-default' }, + { name: 'my_data_alias' }, + ], + data_streams: [ + { + name: 'kibana_sample_data_logs', + }, + { + name: 'logs-apm.error-default', + }, + { + name: 'metrics-system.cpu-default', + }, + { + name: 'user-activity-stream', + }, + ], + }; + }); + + // --- Test cases for single search terms --- + + test('should return true for an exact match in indices', () => { + expect(checkSourceExistence(mockSources, 'movies')).toBe(true); + }); + + test('should return true for a wildcard match in indices (ending with *)', () => { + expect(checkSourceExistence(mockSources, 'logs-apache*')).toBe(true); + }); + + test('should return true for a wildcard match in indices (ending with -*)', () => { + expect(checkSourceExistence(mockSources, 'logstash-*')).toBe(true); + }); + + test('should return true for an exact match in aliases', () => { + expect(checkSourceExistence(mockSources, '.siem-signals-default')).toBe(true); + }); + + test('should return true for a wildcard match in aliases (ending with *)', () => { + expect(checkSourceExistence(mockSources, '.alerts-default*')).toBe(true); + }); + + test('should return true for a wildcard match in aliases (ending with -*)', () => { + expect(checkSourceExistence(mockSources, 'my_data_alias')).toBe(true); // Exact match, but testing if logic handles non-wildcard + expect(checkSourceExistence(mockSources, 'my_data*')).toBe(true); // Wildcard for my_data_alias + }); + + test('should return true for an exact match in data_streams', () => { + expect(checkSourceExistence(mockSources, 'kibana_sample_data_logs')).toBe(true); + }); + + test('should return true for a wildcard match in data_streams (ending with *)', () => { + expect(checkSourceExistence(mockSources, 'metrics-system*')).toBe(true); + }); + + test('should return true for a wildcard match in data_streams (ending with -*)', () => { + expect(checkSourceExistence(mockSources, 'logs-apm.error-*')).toBe(true); + }); + + test('should return false if a single search term is not found anywhere', () => { + expect(checkSourceExistence(mockSources, 'nonexistent-source')).toBe(false); + }); + + test('should return false if a wildcard search term finds no match', () => { + expect(checkSourceExistence(mockSources, 'no-match*')).toBe(false); + }); + + test('should distinguish between exact match and wildcard for same prefix', () => { + expect(checkSourceExistence(mockSources, 'movies')).toBe(true); // Exact + expect(checkSourceExistence(mockSources, 'mov*')).toBe(true); // Wildcard + expect(checkSourceExistence(mockSources, 'movie-logs')).toBe(false); // Does not exist + }); + + // --- Test cases for comma-separated search terms --- + + test('should return true if all comma-separated terms are found', () => { + expect( + checkSourceExistence(mockSources, 'movies, logs-apache_error, metrics-system.cpu-default') + ).toBe(true); + }); + + test('should return true if all comma-separated terms including wildcards are found', () => { + expect( + checkSourceExistence( + mockSources, + 'movies, logs-*, .alerts-default.alerts-default, user-activity*' + ) + ).toBe(true); + }); + + test('should return false if at least one comma-separated term is not found', () => { + expect( + checkSourceExistence(mockSources, 'movies, nonexistent, metrics-system.cpu-default') + ).toBe(false); + }); + + test('should handle leading/trailing spaces in comma-separated terms', () => { + expect( + checkSourceExistence(mockSources, ' movies ,logstash-0 , .siem-signals-default ') + ).toBe(true); + }); + }); + + describe('findMatchingIndicesFromPattern', () => { + let registry: Map; + + beforeEach(() => { + // Initialize a fresh registry for each test + registry = new Map(); + registry.set('oblt>logs-2023-10-01', [ + { name: 'attr1', query: 'from attr1' }, + { name: 'attr2', query: 'from attr2' }, + ]); + registry.set('oblt>logs-2024-01-15', [ + { name: 'attrA', query: 'from attrA' }, + { name: 'attrB', query: 'from attrB' }, + ]); + registry.set('oblt>metrics-cpu', [ + { name: 'load', query: 'from load' }, + { name: 'usage', query: 'from usage' }, + ]); + registry.set('oblt>logs-nginx-access', [ + { name: 'status', query: 'from status' }, + { name: 'bytes', query: 'from bytes' }, + ]); + registry.set('oblt>my_logs', [{ name: 'logstash', query: 'from logstash' }]); + registry.set('oblt>data', [{ name: 'data1', query: 'from data1' }]); + registry.set('oblt>another-logs-index', [{ name: 'info', query: 'from info' }]); + registry.set('oblt>logs', [{ name: 'plain_logs', query: 'from plain_logs' }]); + registry.set('oblt>orders-2023-q1', []); + registry.set('oblt>orders-2023-q2', []); + registry.set('oblt>errors_123', []); + registry.set('oblt>errors_abc', []); + registry.set('oblt>pattern*', [{ name: 'pattern', query: 'from pattern*' }]); + }); + + // Matching with a wildcard at the end (e.g., "logs*") + test('should return all indices matching a wildcard pattern like "logs*"', () => { + const result = findMatchingIndicesFromPattern(registry, 'logs*'); + const expected = ['logs-2023-10-01', 'logs-2024-01-15', 'logs-nginx-access', 'logs'].sort(); // Sort for consistent order + + expect(result.sort()).toEqual(expected); + }); + + // Matching with a wildcard in the middle (e.g., "metrics-*") + test('should return all indices matching a wildcard pattern like "metrics-*"', () => { + const result = findMatchingIndicesFromPattern(registry, 'metrics-*'); + const expected = ['metrics-cpu'].sort(); + expect(result.sort()).toEqual(expected); + }); + + // Exact match (e.g., "my_logs") + test('should return the exact index name for an exact match pattern', () => { + const result = findMatchingIndicesFromPattern(registry, 'my_logs'); + const expected = ['my_logs'].sort(); + expect(result.sort()).toEqual(expected); + }); + + // No matching indices + test('should return an empty array if no indices match the pattern', () => { + const result = findMatchingIndicesFromPattern(registry, 'nonexistent*'); + expect(result).toEqual([]); + }); + + // Matching a single character wildcard (e.g., "data*") + test('should match an index that is just the prefix for a wildcard pattern', () => { + const result = findMatchingIndicesFromPattern(registry, 'data*'); + expect(result).toEqual(['data']); + }); + + // More specific wildcard match + test('should correctly match a more specific wildcard like "another-logs-*"', () => { + const result = findMatchingIndicesFromPattern(registry, 'another-logs-*'); + expect(result).toEqual(['another-logs-index']); + }); + + // Pattern that looks like a regex special character + test('should correctly handle patterns that contain regex special characters', () => { + registry.set('my.special.index', []); + registry.set('my-special-index', []); + const result = findMatchingIndicesFromPattern(registry, 'my.special.index'); + expect(result).toEqual(['my.special.index']); + }); + + // Empty registry + test('should return an empty array if the registry is empty', () => { + const emptyRegistry = new Map(); + const result = findMatchingIndicesFromPattern(emptyRegistry, 'logs*'); + expect(result).toEqual([]); + }); + + // Pattern that matches part of the index name + test('should not match partial names without wildcard', () => { + const result = findMatchingIndicesFromPattern(registry, 'logs'); // Looking for exact 'logs' + expect(result).toEqual(['logs']); // Should only return "logs", not "logs-..." + }); + + // Case sensitivity (assuming patterns are case-sensitive) + test('should respect case sensitivity', () => { + registry.set('Logs', []); + const result = findMatchingIndicesFromPattern(registry, 'logs*'); + const expected = ['logs-2023-10-01', 'logs-2024-01-15', 'logs-nginx-access', 'logs'].sort(); + expect(result.sort()).toEqual(expected); // "Logs" should not be included + }); + + // Pattern with no wildcard but common prefix + test('should not match common prefix if no wildcard is used', () => { + const result = findMatchingIndicesFromPattern(registry, 'orders'); + expect(result).toEqual([]); + }); + + // Pattern with no wildcard but common prefix and exact match + test('should match exact pattern even if it has a common prefix with others', () => { + registry.set('my-order', []); + const result = findMatchingIndicesFromPattern(registry, 'my-order'); + expect(result).toEqual(['my-order']); + }); + + // Wildcard with numerical suffix + test('should match wildcard patterns with numerical suffixes correctly', () => { + const result = findMatchingIndicesFromPattern(registry, 'errors_*'); + const expected = ['errors_123', 'errors_abc'].sort(); + expect(result.sort()).toEqual(expected); + }); + + // Matching a pattern that looks like a regex special character + test('should correctly match a single string that matches a wildcard', () => { + const result = findMatchingIndicesFromPattern(registry, 'pattern-01022024'); + expect(result).toEqual(['pattern*']); + }); + }); +}); diff --git a/src/platform/plugins/shared/esql/server/extensions_registry/utils.ts b/src/platform/plugins/shared/esql/server/extensions_registry/utils.ts new file mode 100644 index 0000000000000..4834980f60e30 --- /dev/null +++ b/src/platform/plugins/shared/esql/server/extensions_registry/utils.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import type { RecommendedQuery, ResolveIndexResponse } from '@kbn/esql-types'; + +/** + * Returns a boolean if the pattern exists in the sources. + * @param pattern The pattern string (e.g., "logs*", "my_index"). + * @param sources The ResolveIndexResponse object containing indices, aliases, and data streams. + * @returns A boolean indicating if the pattern exists in the sources. + */ +export function checkSourceExistence(sources: ResolveIndexResponse, inputString: string): boolean { + const searchTerms = inputString.split(',').map((term) => term.trim()); + + for (const searchTerm of searchTerms) { + let found = false; + + // Check in indices + if (sources.indices) { + for (const index of sources.indices) { + if ( + searchTerm === index.name || + (searchTerm.endsWith('*') && index.name.startsWith(searchTerm.slice(0, -1))) || + (searchTerm.endsWith('-*') && index.name.startsWith(searchTerm.slice(0, -1))) + ) { + found = true; + break; + } + } + } + if (found) continue; + + // Check in aliases + if (sources.aliases) { + for (const alias of sources.aliases) { + if ( + searchTerm === alias.name || + (searchTerm.endsWith('*') && alias.name.startsWith(searchTerm.slice(0, -1))) || + (searchTerm.endsWith('-*') && alias.name.startsWith(searchTerm.slice(0, -1))) + ) { + found = true; + break; + } + } + } + if (found) continue; + + // Check in data_streams + if (sources.data_streams) { + for (const dataStream of sources.data_streams) { + if ( + searchTerm === dataStream.name || + (searchTerm.endsWith('*') && dataStream.name.startsWith(searchTerm.slice(0, -1))) || + (searchTerm.endsWith('-*') && dataStream.name.startsWith(searchTerm.slice(0, -1))) + ) { + found = true; + break; + } + } + } + + if (!found) { + return false; // If even one search term is not found, return false + } + } + + return true; // All search terms were found +} + +/** + * Creates a RegExp object from a given pattern string, handling wildcards (*). + * @param pattern The pattern string (e.g., "logs*", "my_index"). + * @returns A RegExp object. + */ +function createPatternRegex(pattern: string): RegExp { + const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + if (escapedPattern.endsWith('\\*')) { + // If the pattern ends with '*', remove it and create a regex that matches the prefix + const prefix = escapedPattern.slice(0, -2); // Remove the escaped '*' + // Match prefix followed by anything, up to the end of the string + return new RegExp(`^${prefix}.*$`); + } else { + // Exact match if no '*' at the end + // Match the entire string exactly + return new RegExp(`^${escapedPattern}$`); + } +} + +/** + * Finds matches from the registry, given a pattern. + * @param registry The registry map containing index names and their corresponding queries. + * @param pattern The pattern string (e.g., "logs*", "my_index", "logs-02122024"). + * @returns An array of matching index names. + */ +export function findMatchingIndicesFromPattern( + registry: Map, + indexPattern: string +): string[] { + const matchingIndices: string[] = []; + const indexPatternRegex = createPatternRegex(indexPattern); + + for (const [registryId, _] of registry.entries()) { + const index = registryId.split('>')[1] || registryId; // Extract the index from the registryId + if (indexPatternRegex.test(index)) { + matchingIndices.push(index); + } else { + const regex = createPatternRegex(index); + if (regex.test(indexPattern)) { + matchingIndices.push(index); + } + } + } + + return matchingIndices; +} diff --git a/src/platform/plugins/shared/esql/server/index.ts b/src/platform/plugins/shared/esql/server/index.ts index c4b769d83e716..da90a5e2bf737 100644 --- a/src/platform/plugins/shared/esql/server/index.ts +++ b/src/platform/plugins/shared/esql/server/index.ts @@ -8,8 +8,11 @@ */ import type { PluginInitializerContext } from '@kbn/core/server'; +import type { EsqlServerPluginSetup } from './plugin'; export const plugin = async (initContext: PluginInitializerContext) => { const { EsqlServerPlugin } = await import('./plugin'); return new EsqlServerPlugin(initContext); }; + +export type { EsqlServerPluginSetup as PluginSetup }; diff --git a/src/platform/plugins/shared/esql/server/plugin.ts b/src/platform/plugins/shared/esql/server/plugin.ts index 1b145c2f43411..e6ba366cc051c 100644 --- a/src/platform/plugins/shared/esql/server/plugin.ts +++ b/src/platform/plugins/shared/esql/server/plugin.ts @@ -12,9 +12,15 @@ import { schema } from '@kbn/config-schema'; import { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; import { getUiSettings } from './ui_settings'; import { registerRoutes } from './routes'; +import { ESQLExtensionsRegistry } from './extensions_registry'; -export class EsqlServerPlugin implements Plugin { +export interface EsqlServerPluginSetup { + getExtensionsRegistry: () => ESQLExtensionsRegistry; +} + +export class EsqlServerPlugin implements Plugin { private readonly initContext: PluginInitializerContext; + private extensionsRegistry: ESQLExtensionsRegistry = new ESQLExtensionsRegistry(); constructor(initContext: PluginInitializerContext) { this.initContext = { ...initContext }; @@ -33,9 +39,11 @@ export class EsqlServerPlugin implements Plugin { }), }); - registerRoutes(core, initContext); + registerRoutes(core, this.extensionsRegistry, initContext); - return {}; + return { + getExtensionsRegistry: () => this.extensionsRegistry, + }; } public start(core: CoreStart) { diff --git a/src/platform/plugins/shared/esql/server/routes/get_esql_extensions_route.ts b/src/platform/plugins/shared/esql/server/routes/get_esql_extensions_route.ts new file mode 100644 index 0000000000000..d53e1a8261773 --- /dev/null +++ b/src/platform/plugins/shared/esql/server/routes/get_esql_extensions_route.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import { schema } from '@kbn/config-schema'; +import { + type KibanaProject as SolutionId, + KIBANA_PROJECTS as VALID_SOLUTION_IDS, +} from '@kbn/projects-solutions-groups'; +import type { IRouter, PluginInitializerContext } from '@kbn/core/server'; +import type { ResolveIndexResponse } from '@kbn/esql-types'; +import type { ESQLExtensionsRegistry } from '../extensions_registry'; + +/** + * Type guard to check if a string is a valid SolutionId. + * @param str The string to check. + * @returns True if the string is a valid SolutionId, false otherwise. + */ +function isSolutionId(str: string): str is SolutionId { + return VALID_SOLUTION_IDS.includes(str as SolutionId); +} + +/** + * Registers the ESQL extensions route. + * This route handles requests for ESQL extensions based on the provided solutionId and query. + * + * @param router The IRouter instance to register the route with. + * @param extensionsRegistry The ESQLExtensionsRegistry instance to use for fetching recommended queries. + * @param logger The logger instance from the PluginInitializerContext. + */ +export const registerESQLExtensionsRoute = ( + router: IRouter, + extensionsRegistry: ESQLExtensionsRegistry, + { logger }: PluginInitializerContext +) => { + router.get( + { + path: '/internal/esql_registry/extensions/{solutionId}/{query}', + security: { + authz: { + enabled: false, + reason: 'This route delegates authorization to the scoped ES client', + }, + }, + validate: { + params: schema.object({ + solutionId: schema.oneOf( + [ + schema.literal('es'), + schema.literal('oblt'), + schema.literal('security'), + schema.literal('chat'), + ], + { + defaultValue: 'oblt', // Default to 'oblt' if no solutionId is provided + } + ), + query: schema.string(), + }), + }, + }, + async (requestHandlerContext, request, response) => { + const core = await requestHandlerContext.core; + const client = core.elasticsearch.client.asCurrentUser; + const { query, solutionId } = request.params; + try { + const sources = (await client.indices.resolveIndex({ + name: '*', + expand_wildcards: 'open', + })) as ResolveIndexResponse; + // Validate solutionId + const validSolutionId = isSolutionId(solutionId) ? solutionId : 'oblt'; // No solutionId provided, or invalid + // return the recommended queries for now, we will add more extensions later + const recommendedQueries = extensionsRegistry.getRecommendedQueries( + query, + sources, + validSolutionId + ); + return response.ok({ + body: recommendedQueries, + }); + } catch (error) { + logger.get().debug(error); + throw error; + } + } + ); +}; diff --git a/src/platform/plugins/shared/esql/server/routes/index.ts b/src/platform/plugins/shared/esql/server/routes/index.ts index 72803bf621233..cd8445670debe 100644 --- a/src/platform/plugins/shared/esql/server/routes/index.ts +++ b/src/platform/plugins/shared/esql/server/routes/index.ts @@ -8,13 +8,20 @@ */ import type { CoreSetup, PluginInitializerContext } from '@kbn/core/server'; +import type { ESQLExtensionsRegistry } from '../extensions_registry'; import { registerGetJoinIndicesRoute } from './get_join_indices'; import { registerGetTimeseriesIndicesRoute } from './get_timeseries_indices'; +import { registerESQLExtensionsRoute } from './get_esql_extensions_route'; -export const registerRoutes = (setup: CoreSetup, initContext: PluginInitializerContext) => { +export const registerRoutes = ( + setup: CoreSetup, + extensionsRegistry: ESQLExtensionsRegistry, + initContext: PluginInitializerContext +) => { const router = setup.http.createRouter(); registerGetJoinIndicesRoute(router, initContext); registerGetTimeseriesIndicesRoute(router, initContext); + registerESQLExtensionsRoute(router, extensionsRegistry, initContext); }; diff --git a/src/platform/plugins/shared/esql/tsconfig.json b/src/platform/plugins/shared/esql/tsconfig.json index 8307f0dc3e5a4..b8af8d687e6bb 100644 --- a/src/platform/plugins/shared/esql/tsconfig.json +++ b/src/platform/plugins/shared/esql/tsconfig.json @@ -34,7 +34,8 @@ "@kbn/i18n-react", "@kbn/visualization-utils", "@kbn/esql-types", - "@kbn/licensing-plugin" + "@kbn/licensing-plugin", + "@kbn/projects-solutions-groups" ], "exclude": [ "target/**/*",