Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make Indices discoverable in Kibana Global Search #175473

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions x-pack/plugins/enterprise_search/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '.';
Expand Down Expand Up @@ -343,6 +344,7 @@ export class EnterpriseSearchPlugin implements Plugin {

if (globalSearch) {
globalSearch.registerResultProvider(getSearchResultProvider(http.basePath, config));
globalSearch.registerResultProvider(getIndicesSearchResultProvider(http.basePath));
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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<GlobalSearchProviderResult[]> => {
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',
};
}
6 changes: 6 additions & 0 deletions x-pack/plugins/global_search/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<void>;
/**
* 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.
Expand Down
6 changes: 6 additions & 0 deletions x-pack/plugins/global_search/public/services/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { Observable } from 'rxjs';
import { IScopedClusterClient } from '@kbn/core/server';

/**
* Options for the server-side {@link GlobalSearchPluginStart.find | find API}
Expand All @@ -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<void>;
/**
* 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;
}
3 changes: 2 additions & 1 deletion x-pack/plugins/global_search/server/routes/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ describe('POST /internal/global_search/find', () => {
{
preference: 'custom-pref',
aborted$: expect.any(Object),
client: expect.any(Object),
}
);
});
Expand Down
6 changes: 6 additions & 0 deletions x-pack/plugins/global_search/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
Capabilities,
IRouter,
CustomRequestHandlerContext,
IScopedClusterClient,
} from '@kbn/core/server';
import {
GlobalSearchBatchedResults,
Expand Down Expand Up @@ -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<void>;
/**
* 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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpicking here, but I think it's neater to pass the concrete Elasticsearch Client with permissions (so the result of client.asCurrentUser) instead of the cluster client which still needs to choose between asCurrentUser and asInternalUser, if possible?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_index_data expects a ClusterClient in here and that is why I kept it as clusterClient. Should I write a different method which expects only currentUser?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably pass that function an ElasticsearchClient in all of its usages, but maybe that's too much effort for this PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetch_indices and indices_search_results_providers* both is using this function and probably their related test files. So not much, however, then we should update getSearchIndexData function and its usage too.

Is it okay if I create a separate small PR to handle this update for both functions?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sphilipse : Can I get an approval if everything looks good?

}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading