diff --git a/x-pack/plugins/enterprise_search/public/assets/source_icons/index.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/index.svg new file mode 100644 index 0000000000000..6af8b7e92afe9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/assets/source_icons/index.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 7a764e9ef6fb5..8d493c0a4d59b 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -80,6 +80,7 @@ import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/t import { GlobalConfigService } from './services/global_config_service'; import { uiSettings as enterpriseSearchUISettings } from './ui_settings'; +import { getIndicesSearchResultProvider } from './utils/indices_search_result_provider'; import { getSearchResultProvider } from './utils/search_result_provider'; import { ConfigType } from '.'; @@ -343,6 +344,7 @@ export class EnterpriseSearchPlugin implements Plugin { if (globalSearch) { globalSearch.registerResultProvider(getSearchResultProvider(http.basePath, config)); + globalSearch.registerResultProvider(getIndicesSearchResultProvider(http.basePath)); } } diff --git a/x-pack/plugins/enterprise_search/server/utils/indices_search_result_provider.test.ts b/x-pack/plugins/enterprise_search/server/utils/indices_search_result_provider.test.ts new file mode 100644 index 0000000000000..facae46be72a4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/utils/indices_search_result_provider.test.ts @@ -0,0 +1,212 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { NEVER, lastValueFrom } from 'rxjs'; + +import { IScopedClusterClient } from '@kbn/core/server'; + +import { ENTERPRISE_SEARCH_CONTENT_PLUGIN } from '../../common/constants'; + +import { getIndicesSearchResultProvider } from './indices_search_result_provider'; + +describe('Enterprise Search - indices search provider', () => { + const basePathMock = { + prepend: (input: string) => `/kbn${input}`, + } as any; + + const indicesSearchResultProvider = getIndicesSearchResultProvider(basePathMock); + + const regularIndexResponse = { + 'search-github-api': { + aliases: {}, + }, + 'search-msft-sql-index': { + aliases: {}, + }, + }; + + const mockClient = { + asCurrentUser: { + count: jest.fn().mockReturnValue({ count: 100 }), + indices: { + get: jest.fn(), + stats: jest.fn(), + }, + security: { + hasPrivileges: jest.fn(), + }, + }, + asInternalUser: {}, + }; + const client = mockClient as unknown as IScopedClusterClient; + mockClient.asCurrentUser.indices.get.mockResolvedValue(regularIndexResponse); + + const githubIndex = { + id: 'search-github-api', + score: 75, + title: 'search-github-api', + type: 'Index', + url: { + path: `${ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL}/search_indices/search-github-api`, + prependBasePath: true, + }, + icon: '/kbn/plugins/enterpriseSearch/assets/source_icons/index.svg', + }; + + const msftIndex = { + id: 'search-msft-sql-index', + score: 75, + title: 'search-msft-sql-index', + type: 'Index', + url: { + path: `${ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL}/search_indices/search-msft-sql-index`, + prependBasePath: true, + }, + icon: '/kbn/plugins/enterpriseSearch/assets/source_icons/index.svg', + }; + + beforeEach(() => {}); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('find', () => { + it('returns formatted results', async () => { + const results = await lastValueFrom( + indicesSearchResultProvider.find( + { term: 'search-github-api' }, + { + aborted$: NEVER, + client, + maxResults: 100, + preference: '', + }, + {} as any + ) + ); + expect(results).toEqual([{ ...githubIndex, score: 100 }]); + }); + + it('returns all matched results', async () => { + const results = await lastValueFrom( + indicesSearchResultProvider.find( + { term: 'search' }, + { + aborted$: NEVER, + client, + maxResults: 100, + preference: '', + }, + {} as any + ) + ); + expect(results).toEqual([ + { ...githubIndex, score: 90 }, + { ...msftIndex, score: 90 }, + ]); + }); + + it('returns all indices on empty string', async () => { + const results = await lastValueFrom( + indicesSearchResultProvider.find( + { term: '' }, + { + aborted$: NEVER, + client, + maxResults: 100, + preference: '', + }, + {} as any + ) + ); + expect(results).toHaveLength(0); + }); + + it('respect maximum results', async () => { + const results = await lastValueFrom( + indicesSearchResultProvider.find( + { term: 'search' }, + { + aborted$: NEVER, + client, + maxResults: 1, + preference: '', + }, + {} as any + ) + ); + expect(results).toEqual([{ ...githubIndex, score: 90 }]); + }); + + describe('returns empty results', () => { + it('when term does not match with created indices', async () => { + const results = await lastValueFrom( + indicesSearchResultProvider.find( + { term: 'sample' }, + { + aborted$: NEVER, + client, + maxResults: 100, + preference: '', + }, + {} as any + ) + ); + expect(results).toEqual([]); + }); + + it('if client is undefined', async () => { + const results = await lastValueFrom( + indicesSearchResultProvider.find( + { term: 'sample' }, + { + aborted$: NEVER, + client: undefined, + maxResults: 100, + preference: '', + }, + {} as any + ) + ); + expect(results).toEqual([]); + }); + + it('if tag is specified', async () => { + const results = await lastValueFrom( + indicesSearchResultProvider.find( + { term: 'search', tags: ['tag'] }, + { + aborted$: NEVER, + client, + maxResults: 100, + preference: '', + }, + {} as any + ) + ); + expect(results).toEqual([]); + }); + + it('if unknown type is specified', async () => { + const results = await lastValueFrom( + indicesSearchResultProvider.find( + { term: 'search', types: ['tag'] }, + { + aborted$: NEVER, + client, + maxResults: 100, + preference: '', + }, + {} as any + ) + ); + expect(results).toEqual([]); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/utils/indices_search_result_provider.ts b/x-pack/plugins/enterprise_search/server/utils/indices_search_result_provider.ts new file mode 100644 index 0000000000000..c826be87fcdfc --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/utils/indices_search_result_provider.ts @@ -0,0 +1,68 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { from, takeUntil } from 'rxjs'; + +import { IBasePath } from '@kbn/core-http-server'; +import { + GlobalSearchProviderResult, + GlobalSearchResultProvider, +} from '@kbn/global-search-plugin/server'; +import { i18n } from '@kbn/i18n'; + +import { ENTERPRISE_SEARCH_CONTENT_PLUGIN } from '../../common/constants'; + +import { getIndexData } from '../lib/indices/utils/get_index_data'; + +export function getIndicesSearchResultProvider(basePath: IBasePath): GlobalSearchResultProvider { + return { + find: ({ term, types, tags }, { aborted$, client, maxResults }) => { + if (!client || !term || tags || (types && !types.includes('indices'))) { + return from([[]]); + } + const fetchIndices = async (): Promise => { + const { indexNames } = await getIndexData(client, false, false, term); + + const searchResults: GlobalSearchProviderResult[] = indexNames + .map((indexName) => { + let score = 0; + const searchTerm = (term || '').toLowerCase(); + const searchName = indexName.toLowerCase(); + if (!searchTerm) { + score = 80; + } else if (searchName === searchTerm) { + score = 100; + } else if (searchName.startsWith(searchTerm)) { + score = 90; + } else if (searchName.includes(searchTerm)) { + score = 75; + } + + return { + id: indexName, + title: indexName, + icon: basePath.prepend('/plugins/enterpriseSearch/assets/source_icons/index.svg'), + type: i18n.translate('xpack.enterpriseSearch.searchIndexProvider.type.name', { + defaultMessage: 'Index', + }), + url: { + path: `${ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL}/search_indices/${indexName}`, + prependBasePath: true, + }, + score, + }; + }) + .filter(({ score }) => score > 0) + .slice(0, maxResults); + return searchResults; + }; + return from(fetchIndices()).pipe(takeUntil(aborted$)); + }, + getSearchableTypes: () => ['indices'], + id: 'enterpriseSearchIndices', + }; +} diff --git a/x-pack/plugins/global_search/common/types.ts b/x-pack/plugins/global_search/common/types.ts index fabae7ea01e8f..676cb421c4a15 100644 --- a/x-pack/plugins/global_search/common/types.ts +++ b/x-pack/plugins/global_search/common/types.ts @@ -7,6 +7,7 @@ import { Observable } from 'rxjs'; import { Serializable } from '@kbn/utility-types'; +import { IScopedClusterClient } from '@kbn/core/server'; /** * Options provided to {@link GlobalSearchResultProvider | a result provider}'s `find` method. @@ -26,6 +27,11 @@ export interface GlobalSearchProviderFindOptions { * this can (and should) be used to cancel any pending asynchronous task and complete the result observable from within the provider. */ aborted$: Observable; + /** + * A ES client of type IScopedClusterClient is passed to the `find` call. + * When performing calls to ES, the interested provider can utilize this parameter to identify the specific cluster. + */ + client?: IScopedClusterClient; /** * The total maximum number of results (including all batches, not per emission) that should be returned by the provider for a given `find` request. * Any result emitted exceeding this quota will be ignored by the service and not emitted to the consumer. diff --git a/x-pack/plugins/global_search/public/services/types.ts b/x-pack/plugins/global_search/public/services/types.ts index 169b1538d13d9..7cc622b82fba8 100644 --- a/x-pack/plugins/global_search/public/services/types.ts +++ b/x-pack/plugins/global_search/public/services/types.ts @@ -6,6 +6,7 @@ */ import { Observable } from 'rxjs'; +import { IScopedClusterClient } from '@kbn/core/server'; /** * Options for the server-side {@link GlobalSearchPluginStart.find | find API} @@ -25,4 +26,9 @@ export interface GlobalSearchFindOptions { * If/when provided and emitting, the result observable will be completed and no further result emission will be performed. */ aborted$?: Observable; + /** + * A ES client of type IScopedClusterClient is passed to the `find` call. + * When performing calls to ES, the interested provider can utilize this parameter to identify the specific cluster. + */ + client?: IScopedClusterClient; } diff --git a/x-pack/plugins/global_search/server/routes/find.ts b/x-pack/plugins/global_search/server/routes/find.ts index ec491454f7a88..64aa76ee64a70 100644 --- a/x-pack/plugins/global_search/server/routes/find.ts +++ b/x-pack/plugins/global_search/server/routes/find.ts @@ -33,8 +33,9 @@ export const registerInternalFindRoute = (router: GlobalSearchRouter) => { const { params, options } = req.body; try { const globalSearch = await ctx.globalSearch; + const { client } = (await ctx.core).elasticsearch; const allResults = await globalSearch - .find(params, { ...options, aborted$: req.events.aborted$ }) + .find(params, { ...options, aborted$: req.events.aborted$, client }) .pipe( map((batch) => batch.results), reduce((acc, results) => [...acc, ...results]) diff --git a/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts b/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts index fc53c8d01ebfa..0551e4338c1d3 100644 --- a/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts +++ b/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts @@ -76,6 +76,7 @@ describe('POST /internal/global_search/find', () => { { preference: 'custom-pref', aborted$: expect.any(Object), + client: expect.any(Object), } ); }); diff --git a/x-pack/plugins/global_search/server/types.ts b/x-pack/plugins/global_search/server/types.ts index 10a7bafe850dd..21de10af6a72f 100644 --- a/x-pack/plugins/global_search/server/types.ts +++ b/x-pack/plugins/global_search/server/types.ts @@ -13,6 +13,7 @@ import type { Capabilities, IRouter, CustomRequestHandlerContext, + IScopedClusterClient, } from '@kbn/core/server'; import { GlobalSearchBatchedResults, @@ -92,6 +93,11 @@ export interface GlobalSearchFindOptions { * If/when provided and emitting, no further result emission will be performed and the result observable will be completed. */ aborted$?: Observable; + /** + * A ES client of type IScopedClusterClient is passed to the `find` call. + * When performing calls to ES, the interested provider can utilize this parameter to identify the specific cluster. + */ + client?: IScopedClusterClient; } /** diff --git a/x-pack/plugins/global_search_bar/public/lib/result_to_option.test.ts b/x-pack/plugins/global_search_bar/public/lib/result_to_option.test.ts index d60453ca37d85..92589202cd16a 100644 --- a/x-pack/plugins/global_search_bar/public/lib/result_to_option.test.ts +++ b/x-pack/plugins/global_search_bar/public/lib/result_to_option.test.ts @@ -52,6 +52,15 @@ describe('resultToOption', () => { ); }); + it('uses icon for `index` type', () => { + const input = createSearchResult({ type: 'index', icon: 'index-icon' }); + expect(resultToOption(input, [])).toEqual( + expect.objectContaining({ + icon: { type: 'index-icon' }, + }) + ); + }); + it('does not use icon for other types', () => { const input = createSearchResult({ type: 'dashboard', icon: 'dash-icon' }); expect(resultToOption(input, [])).toEqual( diff --git a/x-pack/plugins/global_search_bar/public/lib/result_to_option.tsx b/x-pack/plugins/global_search_bar/public/lib/result_to_option.tsx index 88fa86edbd395..555434a85404f 100644 --- a/x-pack/plugins/global_search_bar/public/lib/result_to_option.tsx +++ b/x-pack/plugins/global_search_bar/public/lib/result_to_option.tsx @@ -25,7 +25,8 @@ export const resultToOption = ( type === 'application' || type === 'integration' || type.toLowerCase() === 'enterprise search' || - type.toLowerCase() === 'search'; + type.toLowerCase() === 'search' || + type.toLowerCase() === 'index'; const option: EuiSelectableTemplateSitewideOption = { key: id, label: title,