Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/platform/packages/shared/kbn-esql-types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ export {
type InferenceEndpointsAutocompleteResult,
type InferenceEndpointAutocompleteItem,
} from './src/inference_endpoint_autocomplete_types';

export { REGISTRY_EXTENSIONS_ROUTE } from './src/constants';
10 changes: 10 additions & 0 deletions src/platform/packages/shared/kbn-esql-types/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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/';
Original file line number Diff line number Diff line change
Expand Up @@ -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';
4 changes: 2 additions & 2 deletions src/platform/plugins/shared/esql/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
};
Expand Down
18 changes: 18 additions & 0 deletions src/platform/plugins/shared/esql/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ export class EsqlServerPlugin implements Plugin<EsqlServerPluginSetup> {
}),
});

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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,43 @@
*/

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';
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(
<KibanaContextProvider services={services}>
<ESQLMenuPopover adHocDataview={adHocDataview} />
</KibanaContextProvider>
);
};

beforeEach(() => {
// Reset mocks before each test
httpModule.http.get.mockClear();
});

it('should render a button', () => {
renderESQLPopover();
expect(screen.getByTestId('esql-menu-button')).toBeInTheDocument();
Expand All @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -35,12 +42,65 @@ export const ESQLMenuPopover: React.FC<ESQLMenuPopoverProps> = ({
onESQLQuerySubmit,
}) => {
const kibana = useKibana<IUnifiedSearchPluginServices>();
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<RecommendedQuery[]>([]);

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]);
Expand All @@ -55,18 +115,30 @@ export const ESQLMenuPopover: React.FC<ESQLMenuPopoverProps> = ({

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,
Expand All @@ -75,7 +147,7 @@ export const ESQLMenuPopover: React.FC<ESQLMenuPopoverProps> = ({
name: i18n.translate('unifiedSearch.query.queryBar.esqlMenu.quickReference', {
defaultMessage: 'Quick Reference',
}),
icon: 'nedocumentationsted',
icon: 'nedocumentationsted', // Typo: Should be 'documentation'
renderItem: () => (
<EuiContextMenuItem
key="quickReference"
Expand Down Expand Up @@ -160,7 +232,21 @@ export const ESQLMenuPopover: React.FC<ESQLMenuPopoverProps> = ({
},
];
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 (
<>
Expand All @@ -179,7 +265,7 @@ export const ESQLMenuPopover: React.FC<ESQLMenuPopoverProps> = ({
}
panelProps={{
['data-test-subj']: 'esql-menu-popover',
css: { width: 240 },
css: esqlMenuPopoverStyles,
}}
isOpen={isESQLMenuPopoverOpen}
closePopover={() => setIsESQLMenuPopoverOpen(false)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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: () => {
Expand Down
Loading
Loading