diff --git a/src/platform/packages/shared/kbn-esql-types/index.ts b/src/platform/packages/shared/kbn-esql-types/index.ts index 9fbe4ded1b0ff..3e5170652cf66 100644 --- a/src/platform/packages/shared/kbn-esql-types/index.ts +++ b/src/platform/packages/shared/kbn-esql-types/index.ts @@ -34,3 +34,5 @@ export { type InferenceEndpointsAutocompleteResult, type InferenceEndpointAutocompleteItem, } from './src/inference_endpoint_autocomplete_types'; + +export { REGISTRY_EXTENSIONS_ROUTE } from './src/constants'; diff --git a/src/platform/packages/shared/kbn-esql-types/src/constants.ts b/src/platform/packages/shared/kbn-esql-types/src/constants.ts new file mode 100644 index 0000000000000..ea361c19f1663 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-types/src/constants.ts @@ -0,0 +1,10 @@ +/* + * 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 const REGISTRY_EXTENSIONS_ROUTE = '/internal/esql_registry/extensions/'; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/index.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/index.ts index 45807e954f52d..fa3474f4fce73 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/index.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/index.ts @@ -63,4 +63,6 @@ export { export { getRecommendedQueries } from './src/autocomplete/recommended_queries/templates'; +export { getRecommendedQueriesTemplatesFromExtensions } from './src/autocomplete/recommended_queries/suggestions'; + export { esqlFunctionNames } from './src/definitions/generated/function_names'; diff --git a/src/platform/plugins/shared/esql/public/plugin.ts b/src/platform/plugins/shared/esql/public/plugin.ts index 6dd792e2c0984..447c98eb1a2c2 100755 --- a/src/platform/plugins/shared/esql/public/plugin.ts +++ b/src/platform/plugins/shared/esql/public/plugin.ts @@ -16,7 +16,7 @@ import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-ty import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; -import type { IndicesAutocompleteResult } from '@kbn/esql-types'; +import { type IndicesAutocompleteResult, REGISTRY_EXTENSIONS_ROUTE } from '@kbn/esql-types'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import type { KibanaProject as SolutionId } from '@kbn/projects-solutions-groups'; @@ -137,7 +137,7 @@ export class EsqlPlugin implements Plugin<{}, EsqlPluginStart> { activeSolutionId: SolutionId ) => { const result = await core.http.get( - `/internal/esql_registry/extensions/${activeSolutionId}/${queryString}` + `${REGISTRY_EXTENSIONS_ROUTE}${activeSolutionId}/${queryString}` ); return result; }; diff --git a/src/platform/plugins/shared/esql/server/plugin.ts b/src/platform/plugins/shared/esql/server/plugin.ts index e6ba366cc051c..80a7a5683d89d 100644 --- a/src/platform/plugins/shared/esql/server/plugin.ts +++ b/src/platform/plugins/shared/esql/server/plugin.ts @@ -39,6 +39,24 @@ export class EsqlServerPlugin implements Plugin { }), }); + this.extensionsRegistry.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' + ); + registerRoutes(core, this.extensionsRegistry, initContext); return { 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 index 201ad6b4bce47..64155dcaa863b 100644 --- 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 @@ -12,7 +12,7 @@ import { 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 ResolveIndexResponse, REGISTRY_EXTENSIONS_ROUTE } from '@kbn/esql-types'; import type { ESQLExtensionsRegistry } from '../extensions_registry'; /** @@ -39,7 +39,7 @@ export const registerESQLExtensionsRoute = ( ) => { router.get( { - path: '/internal/esql_registry/extensions/{solutionId}/{query}', + path: `${REGISTRY_EXTENSIONS_ROUTE}{solutionId}/{query}`, security: { authz: { enabled: false, diff --git a/src/platform/plugins/shared/unified_search/public/query_string_input/esql_menu_popover.test.tsx b/src/platform/plugins/shared/unified_search/public/query_string_input/esql_menu_popover.test.tsx index 2a44f1957d266..38315e3504ca6 100644 --- a/src/platform/plugins/shared/unified_search/public/query_string_input/esql_menu_popover.test.tsx +++ b/src/platform/plugins/shared/unified_search/public/query_string_input/esql_menu_popover.test.tsx @@ -8,7 +8,8 @@ */ import React from 'react'; -import { screen, render } from '@testing-library/react'; +import { BehaviorSubject } from 'rxjs'; +import { screen, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { stubIndexPattern } from '@kbn/data-plugin/public/stubs'; @@ -16,12 +17,22 @@ import { coreMock } from '@kbn/core/public/mocks'; import type { DataView } from '@kbn/data-views-plugin/common'; import { ESQLMenuPopover } from './esql_menu_popover'; +const startMock = coreMock.createStart(); +// Mock the necessary services +startMock.chrome.getActiveSolutionNavId$.mockReturnValue(new BehaviorSubject('oblt')); +const httpModule = { + http: { + get: jest.fn().mockResolvedValue({ recommendedQueries: [] }), // Mock the HTTP GET request + }, +}; +const services = { + docLinks: startMock.docLinks, + http: httpModule.http, + chrome: startMock.chrome, +}; + describe('ESQLMenuPopover', () => { const renderESQLPopover = (adHocDataview?: DataView) => { - const startMock = coreMock.createStart(); - const services = { - docLinks: startMock.docLinks, - }; return render( @@ -29,6 +40,11 @@ describe('ESQLMenuPopover', () => { ); }; + beforeEach(() => { + // Reset mocks before each test + httpModule.http.get.mockClear(); + }); + it('should render a button', () => { renderESQLPopover(); expect(screen.getByTestId('esql-menu-button')).toBeInTheDocument(); @@ -49,4 +65,52 @@ describe('ESQLMenuPopover', () => { await userEvent.click(screen.getByRole('button')); expect(screen.queryByTestId('esql-recommended-queries')).toBeInTheDocument(); }); + + it('should fetch ESQL extensions when activeSolutionId and queryForRecommendedQueries are present and the popover is open', async () => { + const mockQueries = [ + { name: 'Count of logs', query: 'FROM logstash1 | STATS COUNT()' }, + { name: 'Average bytes', query: 'FROM logstash2 | STATS AVG(bytes) BY log.level' }, + ]; + + // Configure the mock to resolve with mockQueries + httpModule.http.get.mockResolvedValueOnce({ recommendedQueries: mockQueries }); + + renderESQLPopover(stubIndexPattern); + const esqlQuery = `FROM ${stubIndexPattern.name}`; + + // Assert that http.get was called with the correct URL + await waitFor(() => { + expect(httpModule.http.get).toHaveBeenCalledTimes(1); + expect(httpModule.http.get).toHaveBeenCalledWith( + `/internal/esql_registry/extensions/oblt/${esqlQuery}` + ); + }); + + // open the popover and check for recommended queries + await userEvent.click(screen.getByRole('button')); + expect(screen.queryByTestId('esql-recommended-queries')).toBeInTheDocument(); + // Open the nested section to see the recommended queries + await waitFor(() => userEvent.click(screen.getByTestId('esql-recommended-queries'))); + + await waitFor(() => { + expect(screen.getByText('Count of logs')).toBeInTheDocument(); + expect(screen.getByText('Average bytes')).toBeInTheDocument(); + }); + }); + + it('should handle API call failure gracefully', async () => { + // Configure the mock to reject with an error + httpModule.http.get.mockRejectedValueOnce(new Error('Network error')); + + renderESQLPopover(stubIndexPattern); + // Assert that http.get was called (even if it failed) + await waitFor(() => { + expect(httpModule.http.get).toHaveBeenCalledTimes(1); + }); + + // The catch block does nothing, so we assert that no error is thrown + // and that the static recommended queries are still shown. + await userEvent.click(screen.getByRole('button')); + expect(screen.queryByTestId('esql-recommended-queries')).toBeInTheDocument(); + }); }); diff --git a/src/platform/plugins/shared/unified_search/public/query_string_input/esql_menu_popover.tsx b/src/platform/plugins/shared/unified_search/public/query_string_input/esql_menu_popover.tsx index 1b8226bb03cbf..4341856e7cefa 100644 --- a/src/platform/plugins/shared/unified_search/public/query_string_input/esql_menu_popover.tsx +++ b/src/platform/plugins/shared/unified_search/public/query_string_input/esql_menu_popover.tsx @@ -6,20 +6,27 @@ * 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 React, { useMemo, useState, useCallback } from 'react'; +import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react'; import { EuiPopover, EuiButton, type EuiContextMenuPanelDescriptor, EuiContextMenuItem, EuiContextMenu, + useEuiScrollBar, } from '@elastic/eui'; +import { isEqual } from 'lodash'; +import { css } from '@emotion/react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; +import useObservable from 'react-use/lib/useObservable'; import { i18n } from '@kbn/i18n'; import type { DataView } from '@kbn/data-views-plugin/public'; import { FEEDBACK_LINK } from '@kbn/esql-utils'; -import { getRecommendedQueries } from '@kbn/esql-validation-autocomplete'; +import { type RecommendedQuery, REGISTRY_EXTENSIONS_ROUTE } from '@kbn/esql-types'; +import { + getRecommendedQueries, + getRecommendedQueriesTemplatesFromExtensions, +} from '@kbn/esql-validation-autocomplete'; import { LanguageDocumentationFlyout } from '@kbn/language-documentation'; import type { IUnifiedSearchPluginServices } from '../types'; @@ -35,12 +42,65 @@ export const ESQLMenuPopover: React.FC = ({ onESQLQuerySubmit, }) => { const kibana = useKibana(); + const { docLinks, http, chrome } = kibana.services; - const { docLinks } = kibana.services; + const activeSolutionId = useObservable(chrome.getActiveSolutionNavId$()); const [isESQLMenuPopoverOpen, setIsESQLMenuPopoverOpen] = useState(false); const [isLanguageComponentOpen, setIsLanguageComponentOpen] = useState(false); - const toggleLanguageComponent = useCallback(async () => { + const [solutionsRecommendedQueries, setSolutionsRecommendedQueries] = useState< + RecommendedQuery[] + >([]); + + const { queryForRecommendedQueries, timeFieldName } = useMemo(() => { + if (adHocDataview && typeof adHocDataview !== 'string') { + return { + queryForRecommendedQueries: `FROM ${adHocDataview.name}`, + timeFieldName: + adHocDataview.timeFieldName ?? adHocDataview.fields?.getByType('date')?.[0]?.name, + }; + } + return { + queryForRecommendedQueries: '', + timeFieldName: undefined, + }; + }, [adHocDataview]); + + // Use a ref to store the *previous* fetched recommended queries + const lastFetchedQueries = useRef([]); + + useEffect(() => { + let cancelled = false; + const getESQLExtensions = async () => { + if (!activeSolutionId || !queryForRecommendedQueries) { + return; // Don't fetch if we don't have the active solution or query + } + + try { + const extensions: { recommendedQueries: RecommendedQuery[] } = await http.get( + `${REGISTRY_EXTENSIONS_ROUTE}${activeSolutionId}/${queryForRecommendedQueries}` + ); + + if (cancelled) return; + + // Only update state if the new data is actually different from the *last successfully set* data + if (!isEqual(extensions.recommendedQueries, lastFetchedQueries.current)) { + setSolutionsRecommendedQueries(extensions.recommendedQueries); + lastFetchedQueries.current = extensions.recommendedQueries; // Update the ref with the new data + } + } catch (error) { + // Do nothing if the extensions are not available + } + }; + + getESQLExtensions(); + + return () => { + cancelled = true; + }; + }, [activeSolutionId, http, queryForRecommendedQueries]); + + const toggleLanguageComponent = useCallback(() => { setIsLanguageComponentOpen(!isLanguageComponentOpen); setIsESQLMenuPopoverOpen(false); }, [isLanguageComponentOpen]); @@ -55,18 +115,30 @@ export const ESQLMenuPopover: React.FC = ({ const esqlContextMenuPanels = useMemo(() => { const recommendedQueries = []; - if (adHocDataview && typeof adHocDataview !== 'string') { - const queryString = `FROM ${adHocDataview.name}`; - const timeFieldName = - adHocDataview.timeFieldName ?? adHocDataview.fields?.getByType('date')?.[0]?.name; + // If there are specific recommended queries for the current solution, process them. + if (solutionsRecommendedQueries.length) { + // Extract the core query templates by removing the 'FROM' clause. + const recommendedQueriesTemplatesFromExtensions = + getRecommendedQueriesTemplatesFromExtensions(solutionsRecommendedQueries); + // Construct the full recommended queries by prepending the base 'FROM' command + // and add them to the main list of recommended queries. recommendedQueries.push( - ...getRecommendedQueries({ - fromCommand: queryString, - timeField: timeFieldName, - }) + ...recommendedQueriesTemplatesFromExtensions.map((template) => ({ + label: template.label, + queryString: `${queryForRecommendedQueries}${template.text}`, + })) ); } + // Handle the static recommended queries, no solutions specific + if (queryForRecommendedQueries && timeFieldName) { + const recommendedQueriesFromStaticTemplates = getRecommendedQueries({ + fromCommand: queryForRecommendedQueries, + timeField: timeFieldName, + }); + + recommendedQueries.push(...recommendedQueriesFromStaticTemplates); + } const panels = [ { id: 0, @@ -75,7 +147,7 @@ export const ESQLMenuPopover: React.FC = ({ name: i18n.translate('unifiedSearch.query.queryBar.esqlMenu.quickReference', { defaultMessage: 'Quick Reference', }), - icon: 'nedocumentationsted', + icon: 'nedocumentationsted', // Typo: Should be 'documentation' renderItem: () => ( = ({ }, ]; return panels as EuiContextMenuPanelDescriptor[]; - }, [adHocDataview, docLinks.links.query.queryESQL, onESQLQuerySubmit, toggleLanguageComponent]); + }, [ + docLinks.links.query.queryESQL, + onESQLQuerySubmit, + queryForRecommendedQueries, + timeFieldName, + toggleLanguageComponent, + solutionsRecommendedQueries, // This dependency is fine here, as it *uses* the state + ]); + + const esqlMenuPopoverStyles = css` + width: 240px; + max-height: 350px; + overflow-y: auto; + ${useEuiScrollBar()}; + `; return ( <> @@ -179,7 +265,7 @@ export const ESQLMenuPopover: React.FC = ({ } panelProps={{ ['data-test-subj']: 'esql-menu-popover', - css: { width: 240 }, + css: esqlMenuPopoverStyles, }} isOpen={isESQLMenuPopoverOpen} closePopover={() => setIsESQLMenuPopoverOpen(false)} diff --git a/src/platform/plugins/shared/unified_search/public/query_string_input/query_bar_top_row.test.tsx b/src/platform/plugins/shared/unified_search/public/query_string_input/query_bar_top_row.test.tsx index 1863a8c3ac915..9fefd129e6cfe 100644 --- a/src/platform/plugins/shared/unified_search/public/query_string_input/query_bar_top_row.test.tsx +++ b/src/platform/plugins/shared/unified_search/public/query_string_input/query_bar_top_row.test.tsx @@ -11,6 +11,7 @@ import { mockPersistedLogFactory } from './query_string_input.test.mocks'; import React from 'react'; import { mount, shallow } from 'enzyme'; +import { BehaviorSubject } from 'rxjs'; import { render } from '@testing-library/react'; import { EMPTY } from 'rxjs'; @@ -24,6 +25,7 @@ import { UI_SETTINGS } from '@kbn/data-plugin/common'; import { unifiedSearchPluginMock } from '../mocks'; const startMock = coreMock.createStart(); +startMock.chrome.getActiveSolutionNavId$.mockReturnValue(new BehaviorSubject('oblt')); const mockTimeHistory = { get: () => { diff --git a/src/platform/plugins/shared/unified_search/public/search_bar/search_bar.test.tsx b/src/platform/plugins/shared/unified_search/public/search_bar/search_bar.test.tsx index 869d139db5627..f35388da324f7 100644 --- a/src/platform/plugins/shared/unified_search/public/search_bar/search_bar.test.tsx +++ b/src/platform/plugins/shared/unified_search/public/search_bar/search_bar.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import SearchBar from './search_bar'; - +import { BehaviorSubject } from 'rxjs'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { indexPatternEditorPluginMock as dataViewEditorPluginMock } from '@kbn/data-view-editor-plugin/public/mocks'; import { I18nProvider } from '@kbn/i18n-react'; @@ -84,6 +84,10 @@ function wrapSearchBarInContext(testProps: any) { }, }, }, + chrome: { + ...startMock.chrome, + getActiveSolutionNavId$: jest.fn().mockReturnValue(new BehaviorSubject('oblt')), + }, uiSettings: startMock.uiSettings, settings: startMock.settings, savedObjects: startMock.savedObjects, diff --git a/src/platform/plugins/shared/unified_search/public/types.ts b/src/platform/plugins/shared/unified_search/public/types.ts index f9d0556447778..a058b06bbafd2 100755 --- a/src/platform/plugins/shared/unified_search/public/types.ts +++ b/src/platform/plugins/shared/unified_search/public/types.ts @@ -88,6 +88,7 @@ export interface IUnifiedSearchPluginServices extends Partial { autocomplete: AutocompleteStart; }; appName: string; + chrome: CoreStart['chrome']; uiSettings: CoreStart['uiSettings']; savedObjects: CoreStart['savedObjects']; notifications: CoreStart['notifications']; diff --git a/src/platform/plugins/shared/unified_search/tsconfig.json b/src/platform/plugins/shared/unified_search/tsconfig.json index 31284a5910538..1f3354b512116 100644 --- a/src/platform/plugins/shared/unified_search/tsconfig.json +++ b/src/platform/plugins/shared/unified_search/tsconfig.json @@ -48,7 +48,8 @@ "@kbn/esql-validation-autocomplete", "@kbn/react-kibana-mount", "@kbn/field-utils", - "@kbn/language-documentation" + "@kbn/language-documentation", + "@kbn/esql-types" ], "exclude": [ "target/**/*",