Search apps: Use the alias for the indices list (#162621)

## Summary

This changes the search apps UI to use the alias instead of the list of
indices that is stored in search apps.

Tested with https://github.com/elastic/elasticsearch/pull/98036

Some of the aspects of the UI can be improved, I will follow up with
@julianrosado with some ideas, but for now we are at least handling the
case where the alias is missing without having the UIs crashing, by
showing readable error messages.

When the alias is missing:
<img width="1265" alt="Screenshot 2023-07-31 at 17 02 46"
src="2aa5d3da-4dae-4eff-aff9-a38c221de673">

clicking on the list of indices when the alias is missing:

<img width="1562" alt="Screenshot 2023-07-31 at 17 07 27"
src="bb552833-f64e-4fe7-84f3-e237d40fa79b">

search preview:

<img width="1550" alt="Screenshot 2023-07-31 at 17 07 17"
src="402c8667-884d-4894-a7db-8cc1d9cf98b1">

content:

<img width="1553" alt="Screenshot 2023-07-31 at 17 03 53"
src="aa0a9261-5cab-46f3-a87d-6fa2d87d4b8f">

field capabilities (unchanged):

<img width="1557" alt="Screenshot 2023-07-31 at 17 04 00"
src="9812561f-53ec-497d-9b10-91c65271a503">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Ioana Tagirta 2023-07-31 19:59:03 +02:00 committed by GitHub
parent 3efb83851f
commit 4d00959533
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 120 additions and 36 deletions

View file

@ -22,6 +22,7 @@ export enum ErrorCode {
SEARCH_APPLICATION_ALREADY_EXISTS = 'search_application_already_exists',
SEARCH_APPLICATION_NAME_INVALID = 'search_application_name_invalid',
SEARCH_APPLICATION_NOT_FOUND = 'search_application_not_found',
SEARCH_APPLICATION_ALIAS_NOT_FOUND = 'search_application_alias_not_found',
UNAUTHORIZED = 'unauthorized',
UNCAUGHT_EXCEPTION = 'uncaught_exception',
}

View file

@ -247,8 +247,7 @@ export const SearchApplicationsList: React.FC<ListProps> = ({
{i18n.translate(
'xpack.enterpriseSearch.searchApplications.list.searchBar.description',
{
defaultMessage:
'Locate a search application via name or by its included indices.',
defaultMessage: 'Locate a search application via name.',
}
)}
</EuiText>

View file

@ -0,0 +1,16 @@
/*
* 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 { IScopedClusterClient } from '@kbn/core-elasticsearch-server';
export const fetchAliasIndices = async (client: IScopedClusterClient, aliasName: string) => {
const aliasIndices = await client.asCurrentUser.indices.getAlias({
name: aliasName,
});
return Object.keys(aliasIndices).sort();
};

View file

@ -22,7 +22,7 @@ describe('search applications field_capabilities', () => {
const mockClient = {
asCurrentUser: {
fieldCaps: jest.fn(),
indices: { get: jest.fn() },
indices: { get: jest.fn(), getAlias: jest.fn() },
},
asInternalUser: {},
};
@ -56,6 +56,11 @@ describe('search applications field_capabilities', () => {
'index-001': { aliases: { unit_test_search_application: {} } },
};
const getAliasIndicesResponse = {
'index-001': { aliases: { unit_test_search_application: {} } },
};
mockClient.asCurrentUser.indices.getAlias.mockResolvedValueOnce(getAliasIndicesResponse);
mockClient.asCurrentUser.indices.get.mockResolvedValueOnce(getAllAvailableIndexResponse);
mockClient.asCurrentUser.fieldCaps.mockResolvedValueOnce(fieldCapsResponse);

View file

@ -15,12 +15,15 @@ import {
} from '../../../common/types/search_applications';
import { availableIndices } from './available_indices';
import { fetchAliasIndices } from './fetch_alias_indices';
export const fetchSearchApplicationFieldCapabilities = async (
client: IScopedClusterClient,
searchApplication: EnterpriseSearchApplication
): Promise<EnterpriseSearchApplicationFieldCapabilities> => {
const { name, updated_at_millis, indices } = searchApplication;
const { name, updated_at_millis } = searchApplication;
const indices = await fetchAliasIndices(client, name);
const availableIndicesList = await availableIndices(client, indices);

View file

@ -13,8 +13,13 @@ jest.mock('../../lib/search_applications/field_capabilities', () => ({
jest.mock('../../lib/search_applications/fetch_indices_stats', () => ({
fetchIndicesStats: jest.fn(),
}));
jest.mock('../../lib/search_applications/fetch_alias_indices', () => ({
fetchAliasIndices: jest.fn(),
}));
import { RequestHandlerContext } from '@kbn/core/server';
import { fetchAliasIndices } from '../../lib/search_applications/fetch_alias_indices';
import { fetchIndicesStats } from '../../lib/search_applications/fetch_indices_stats';
import { fetchSearchApplicationFieldCapabilities } from '../../lib/search_applications/field_capabilities';
@ -49,13 +54,11 @@ describe('engines routes', () => {
});
it('GET search applications API creates request', async () => {
mockClient.asCurrentUser.searchApplication.list.mockImplementation(() => ({}));
mockClient.asCurrentUser.searchApplication.list.mockImplementation(() => ({ results: [] }));
const request = { query: {} };
await mockRouter.callRoute({});
await mockRouter.callRoute(request);
expect(mockClient.asCurrentUser.searchApplication.list).toHaveBeenCalledWith(request.query);
expect(mockRouter.response.ok).toHaveBeenCalledWith({
body: {},
});
expect(mockRouter.response.ok).toHaveBeenCalled();
});
it('validates query parameters', () => {
@ -105,32 +108,40 @@ describe('engines routes', () => {
});
it('GET search application API creates request', async () => {
mockClient.asCurrentUser.searchApplication.get.mockImplementation(() => ({}));
await mockRouter.callRoute({
params: { engine_name: 'engine-name' },
});
expect(mockClient.asCurrentUser.searchApplication.get).toHaveBeenCalledWith({
name: 'engine-name',
});
const mock = jest.fn();
const fetchIndicesStatsResponse = [
{ count: 5, health: 'green', name: 'test-index-name-1' },
{ count: 10, health: 'yellow', name: 'test-index-name-2' },
{ count: 0, health: 'red', name: 'test-index-name-3' },
];
const mock = jest.fn();
const fetchAliasIndicesResponse = mock([
'test-index-name-1',
'test-index-name-2',
'test-index-name-3',
]);
const engineResult = {
indices: mock(['test-index-name-1', 'test-index-name-2', 'test-index-name-3']),
name: 'test-engine-1',
indices: fetchAliasIndicesResponse,
name: 'engine-name',
updated_at_millis: 1679847286355,
};
(mockClient.asCurrentUser.searchApplication.get as jest.Mock).mockResolvedValueOnce(
engineResult
);
await mockRouter.callRoute({
params: { engine_name: engineResult.name },
});
(fetchAliasIndices as jest.Mock).mockResolvedValueOnce(fetchAliasIndicesResponse);
expect(fetchAliasIndices).toHaveBeenCalledWith(mockClient, engineResult.name);
(fetchIndicesStats as jest.Mock).mockResolvedValueOnce(fetchIndicesStatsResponse);
expect(fetchIndicesStats).toHaveBeenCalledWith(mockClient, engineResult.indices);
expect(fetchIndicesStats).toHaveBeenCalledWith(mockClient, fetchAliasIndicesResponse);
expect(mockRouter.response.ok).toHaveBeenCalledWith({
body: {},
body: engineResult,
});
});

View file

@ -15,6 +15,7 @@ import {
EnterpriseSearchApplicationUpsertResponse,
} from '../../../common/types/search_applications';
import { createApiKey } from '../../lib/search_applications/create_api_key';
import { fetchAliasIndices } from '../../lib/search_applications/fetch_alias_indices';
import { fetchIndicesStats } from '../../lib/search_applications/fetch_indices_stats';
import { fetchSearchApplicationFieldCapabilities } from '../../lib/search_applications/field_capabilities';
@ -24,6 +25,7 @@ import { createError } from '../../utils/create_error';
import { elasticsearchErrorHandler } from '../../utils/elasticsearch_error_handler';
import {
isInvalidSearchApplicationNameException,
isMissingAliasException,
isNotFoundException,
isVersionConflictEngineException,
} from '../../utils/identify_exceptions';
@ -46,6 +48,19 @@ export function registerSearchApplicationsRoutes({ log, router }: RouteDependenc
request.query
)) as EnterpriseSearchApplicationsResponse;
await Promise.all(
engines.results.map(async (searchApp) => {
try {
searchApp.indices = await fetchAliasIndices(client, searchApp.name);
} catch (error) {
if (isMissingAliasException(error)) {
searchApp.indices = [];
} else {
throw error;
}
}
})
);
return response.ok({ body: engines });
})
);
@ -64,7 +79,18 @@ export function registerSearchApplicationsRoutes({ log, router }: RouteDependenc
const engine = (await client.asCurrentUser.searchApplication.get({
name: request.params.engine_name,
})) as EnterpriseSearchApplication;
const indicesStats = await fetchIndicesStats(client, engine.indices);
let indices: string[];
try {
indices = await fetchAliasIndices(client, engine.name);
} catch (error) {
if (isMissingAliasException(error)) {
indices = [];
} else {
throw error;
}
}
const indicesStats = await fetchIndicesStats(client, indices);
return response.ok({ body: { ...engine, indices: indicesStats } });
})
@ -215,20 +241,15 @@ export function registerSearchApplicationsRoutes({ log, router }: RouteDependenc
validate: { params: schema.object({ engine_name: schema.string() }) },
},
elasticsearchErrorHandler(log, async (context, request, response) => {
try {
const { client } = (await context.core).elasticsearch;
const { client } = (await context.core).elasticsearch;
const engine = (await client.asCurrentUser.searchApplication.get({
let engine;
try {
engine = (await client.asCurrentUser.searchApplication.get({
name: request.params.engine_name,
})) as EnterpriseSearchApplication;
const data = await fetchSearchApplicationFieldCapabilities(client, engine);
return response.ok({
body: data,
headers: { 'content-type': 'application/json' },
});
} catch (e) {
if (isNotFoundException(e)) {
} catch (error) {
if (isNotFoundException(error)) {
return createError({
errorCode: ErrorCode.SEARCH_APPLICATION_NOT_FOUND,
message: i18n.translate(
@ -239,7 +260,28 @@ export function registerSearchApplicationsRoutes({ log, router }: RouteDependenc
statusCode: 404,
});
}
throw e;
throw error;
}
try {
const data = await fetchSearchApplicationFieldCapabilities(client, engine);
return response.ok({
body: data,
headers: { 'content-type': 'application/json' },
});
} catch (error) {
if (isMissingAliasException(error)) {
return createError({
errorCode: ErrorCode.SEARCH_APPLICATION_ALIAS_NOT_FOUND,
message: i18n.translate(
'xpack.enterpriseSearch.server.routes.fetchSearchApplicationFieldCapabilities.missingAliasError',
{ defaultMessage: 'Search application alias is missing.' }
),
response,
statusCode: 404,
});
}
throw error;
}
})
);

View file

@ -19,6 +19,8 @@ export interface ElasticsearchResponseError {
name: 'ResponseError';
}
const MISSING_ALIAS_ERROR = new RegExp(/^alias \[.+\] missing/);
export const isIndexNotFoundException = (error: ElasticsearchResponseError) =>
error?.meta?.body?.error?.type === 'index_not_found_exception';
@ -45,3 +47,8 @@ export const isVersionConflictEngineException = (error: ElasticsearchResponseErr
export const isInvalidSearchApplicationNameException = (error: ElasticsearchResponseError) =>
error.meta?.body?.error?.type === 'invalid_alias_name_exception';
export const isMissingAliasException = (error: ElasticsearchResponseError) =>
error.meta?.statusCode === 404 &&
typeof error.meta?.body?.error === 'string' &&
MISSING_ALIAS_ERROR.test(error.meta?.body?.error);