From 543a047adf09940cdb8eb59e0d79b56148916d9d Mon Sep 17 00:00:00 2001 From: Saarika Bhasi <55930906+saarikabhasi@users.noreply.github.com> Date: Mon, 11 Dec 2023 12:50:41 -0500 Subject: [PATCH] [Serverless Search] Index documents can be browsed in index management (#172635) This PR adds functionality to browse index documents in a index management app. ## Screen Recording https://github.com/elastic/kibana/assets/55930906/d7e565b4-f893-4c56-8e51-3a535cea09ff ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) --- packages/kbn-optimizer/limits.yml | 2 +- .../lib/fetch_search_results.test.ts | 36 ++++----- .../lib/fetch_search_results.ts | 6 +- .../routes/enterprise_search/search.test.ts | 15 ++-- .../server/routes/enterprise_search/search.ts | 2 +- .../components/index_documents/documents.tsx | 76 +++++++++++++++++++ .../index_documents/documents_tab.tsx | 43 +++++++++++ .../hooks/api/use_index_documents.tsx | 47 ++++++++++++ .../hooks/api/use_index_mappings.tsx | 21 +++++ .../serverless_search/public/plugin.ts | 9 ++- .../serverless_search/server/plugin.ts | 2 + .../server/routes/indices_routes.ts | 41 ++++++++++ .../server/routes/mapping_routes.ts | 33 ++++++++ .../plugins/serverless_search/tsconfig.json | 2 + 14 files changed, 300 insertions(+), 35 deletions(-) create mode 100644 x-pack/plugins/serverless_search/public/application/components/index_documents/documents.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/index_documents/documents_tab.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/hooks/api/use_index_documents.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/hooks/api/use_index_mappings.tsx create mode 100644 x-pack/plugins/serverless_search/server/routes/mapping_routes.ts diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index ef99652ee767f..764fe35dd759d 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -131,7 +131,7 @@ pageLoadAssetSize: securitySolutionServerless: 62488 serverless: 16573 serverlessObservability: 68747 - serverlessSearch: 71995 + serverlessSearch: 72995 sessionView: 77750 share: 71239 snapshotRestore: 79032 diff --git a/packages/kbn-search-index-documents/lib/fetch_search_results.test.ts b/packages/kbn-search-index-documents/lib/fetch_search_results.test.ts index dc60dcba437bd..dec058aae0bf2 100644 --- a/packages/kbn-search-index-documents/lib/fetch_search_results.test.ts +++ b/packages/kbn-search-index-documents/lib/fetch_search_results.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { IScopedClusterClient } from '@kbn/core/server'; +import { ElasticsearchClient } from '@kbn/core/server'; import { DEFAULT_DOCS_PER_PAGE } from '../types'; import { fetchSearchResults } from './fetch_search_results'; @@ -14,9 +14,7 @@ import { fetchSearchResults } from './fetch_search_results'; const DEFAULT_FROM_VALUE = 0; describe('fetchSearchResults lib function', () => { const mockClient = { - asCurrentUser: { - search: jest.fn(), - }, + search: jest.fn(), }; const indexName = 'search-regular-index'; @@ -78,15 +76,13 @@ describe('fetchSearchResults lib function', () => { }); it('should return search results with hits', async () => { - mockClient.asCurrentUser.search.mockImplementation(() => - Promise.resolve(mockSearchResponseWithHits) - ); + mockClient.search.mockImplementation(() => Promise.resolve(mockSearchResponseWithHits)); await expect( - fetchSearchResults(mockClient as unknown as IScopedClusterClient, indexName, query) + fetchSearchResults(mockClient as unknown as ElasticsearchClient, indexName, query) ).resolves.toEqual(regularSearchResultsResponse); - expect(mockClient.asCurrentUser.search).toHaveBeenCalledWith({ + expect(mockClient.search).toHaveBeenCalledWith({ from: DEFAULT_FROM_VALUE, index: indexName, q: query, @@ -95,13 +91,11 @@ describe('fetchSearchResults lib function', () => { }); it('should escape quotes in queries and return results with hits', async () => { - mockClient.asCurrentUser.search.mockImplementation(() => - Promise.resolve(mockSearchResponseWithHits) - ); + mockClient.search.mockImplementation(() => Promise.resolve(mockSearchResponseWithHits)); await expect( fetchSearchResults( - mockClient as unknown as IScopedClusterClient, + mockClient as unknown as ElasticsearchClient, indexName, '"yellow banana"', 0, @@ -109,7 +103,7 @@ describe('fetchSearchResults lib function', () => { ) ).resolves.toEqual(regularSearchResultsResponse); - expect(mockClient.asCurrentUser.search).toHaveBeenCalledWith({ + expect(mockClient.search).toHaveBeenCalledWith({ from: DEFAULT_FROM_VALUE, index: indexName, q: '\\"yellow banana\\"', @@ -118,15 +112,13 @@ describe('fetchSearchResults lib function', () => { }); it('should return search results with hits when no query is passed', async () => { - mockClient.asCurrentUser.search.mockImplementation(() => - Promise.resolve(mockSearchResponseWithHits) - ); + mockClient.search.mockImplementation(() => Promise.resolve(mockSearchResponseWithHits)); await expect( - fetchSearchResults(mockClient as unknown as IScopedClusterClient, indexName) + fetchSearchResults(mockClient as unknown as ElasticsearchClient, indexName) ).resolves.toEqual(regularSearchResultsResponse); - expect(mockClient.asCurrentUser.search).toHaveBeenCalledWith({ + expect(mockClient.search).toHaveBeenCalledWith({ from: DEFAULT_FROM_VALUE, index: indexName, size: DEFAULT_DOCS_PER_PAGE, @@ -134,7 +126,7 @@ describe('fetchSearchResults lib function', () => { }); it('should return empty search results', async () => { - mockClient.asCurrentUser.search.mockImplementationOnce(() => + mockClient.search.mockImplementationOnce(() => Promise.resolve({ ...mockSearchResponseWithHits, hits: { @@ -149,10 +141,10 @@ describe('fetchSearchResults lib function', () => { ); await expect( - fetchSearchResults(mockClient as unknown as IScopedClusterClient, indexName, query) + fetchSearchResults(mockClient as unknown as ElasticsearchClient, indexName, query) ).resolves.toEqual(emptySearchResultsResponse); - expect(mockClient.asCurrentUser.search).toHaveBeenCalledWith({ + expect(mockClient.search).toHaveBeenCalledWith({ from: DEFAULT_FROM_VALUE, index: indexName, q: query, diff --git a/packages/kbn-search-index-documents/lib/fetch_search_results.ts b/packages/kbn-search-index-documents/lib/fetch_search_results.ts index 4427fded71c2c..3abca93516c5a 100644 --- a/packages/kbn-search-index-documents/lib/fetch_search_results.ts +++ b/packages/kbn-search-index-documents/lib/fetch_search_results.ts @@ -7,13 +7,13 @@ */ import { SearchHit } from '@elastic/elasticsearch/lib/api/types'; -import { IScopedClusterClient } from '@kbn/core/server'; +import { ElasticsearchClient } from '@kbn/core/server'; import { DEFAULT_DOCS_PER_PAGE, Paginate } from '../types'; import { escapeLuceneChars } from '../utils/escape_lucene_charts'; import { fetchWithPagination } from '../utils/fetch_with_pagination'; export const fetchSearchResults = async ( - client: IScopedClusterClient, + client: ElasticsearchClient, indexName: string, query?: string, from: number = 0, @@ -21,7 +21,7 @@ export const fetchSearchResults = async ( ): Promise> => { const result = await fetchWithPagination( async () => - await client.asCurrentUser.search({ + await client.search({ from, index: indexName, size, diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/search.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/search.test.ts index 0d7fd264b143f..08c82e8523e44 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/search.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/search.test.ts @@ -19,12 +19,15 @@ jest.mock('@kbn/search-index-documents/lib', () => ({ describe('Elasticsearch Search', () => { let mockRouter: MockRouter; - const mockClient = {}; - + const mockClient = { + asCurrentUser: jest.fn(), + }; beforeEach(() => { const context = { - core: Promise.resolve({ elasticsearch: { client: mockClient } }), - } as jest.Mocked; + core: Promise.resolve({ + elasticsearch: { client: mockClient }, + }), + } as unknown as jest.Mocked; mockRouter = new MockRouter({ context, @@ -90,7 +93,7 @@ describe('Elasticsearch Search', () => { beforeEach(() => { const context = { core: Promise.resolve({ elasticsearch: { client: mockClient } }), - } as jest.Mocked; + } as unknown as jest.Mocked; mockRouterNoQuery = new MockRouter({ context, @@ -137,7 +140,7 @@ describe('Elasticsearch Search', () => { }); expect(fetchSearchResults).toHaveBeenCalledWith( - mockClient, + mockClient.asCurrentUser, 'search-index-name', 'banana', 0, diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/search.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/search.ts index f1f6862c58a8f..430740f85644c 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/search.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/search.ts @@ -43,7 +43,7 @@ export function registerSearchRoute({ router, log }: RouteDependencies) { elasticsearchErrorHandler(log, async (context, request, response) => { const indexName = decodeURIComponent(request.params.index_name); const searchQuery = request.body.searchQuery; - const { client } = (await context.core).elasticsearch; + const client = (await context.core).elasticsearch.client.asCurrentUser; const { page = 0, size = ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT } = request.query; const from = page * size; try { diff --git a/x-pack/plugins/serverless_search/public/application/components/index_documents/documents.tsx b/x-pack/plugins/serverless_search/public/application/components/index_documents/documents.tsx new file mode 100644 index 0000000000000..8d719485973e4 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/index_documents/documents.tsx @@ -0,0 +1,76 @@ +/* + * 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 React, { useEffect, useState } from 'react'; + +import { + DocumentList, + DocumentsOverview, + INDEX_DOCUMENTS_META_DEFAULT, +} from '@kbn/search-index-documents'; + +import { i18n } from '@kbn/i18n'; +import { useIndexDocumentSearch } from '../../hooks/api/use_index_documents'; +import { useIndexMappings } from '../../hooks/api/use_index_mappings'; + +const DEFAULT_PAGINATION = { + pageIndex: INDEX_DOCUMENTS_META_DEFAULT.pageIndex, + pageSize: INDEX_DOCUMENTS_META_DEFAULT.pageSize, + totalItemCount: INDEX_DOCUMENTS_META_DEFAULT.totalItemCount, +}; + +interface IndexDocumentsProps { + indexName: string; +} + +export const IndexDocuments: React.FC = ({ indexName }) => { + const [pagination, setPagination] = useState(DEFAULT_PAGINATION); + const [searchQuery, setSearchQuery] = useState(''); + const searchQueryCallback = (query: string) => { + setSearchQuery(query); + }; + const { results: indexDocuments, meta: documentsMeta } = useIndexDocumentSearch( + indexName, + pagination, + searchQuery + ); + + const { data: mappingData } = useIndexMappings(indexName); + + const docs = indexDocuments?.data ?? []; + + useEffect(() => { + setSearchQuery(''); + setPagination(DEFAULT_PAGINATION); + }, [indexName]); + return ( + + {docs.length === 0 && + i18n.translate('xpack.serverlessSearch.indexManagementTab.documents.noMappings', { + defaultMessage: 'No documents found for index', + })} + {docs?.length > 0 && ( + setPagination({ ...pagination, pageIndex })} + setDocsPerPage={(pageSize) => setPagination({ ...pagination, pageSize })} + /> + )} + + } + /> + ); +}; diff --git a/x-pack/plugins/serverless_search/public/application/components/index_documents/documents_tab.tsx b/x-pack/plugins/serverless_search/public/application/components/index_documents/documents_tab.tsx new file mode 100644 index 0000000000000..c71089b5f3b83 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/index_documents/documents_tab.tsx @@ -0,0 +1,43 @@ +/* + * 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 { IndexDetailsTab } from '@kbn/index-management-plugin/common/constants'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { CoreStart } from '@kbn/core-lifecycle-browser'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { ServerlessSearchPluginStartDependencies } from '../../../types'; +import { IndexDocuments } from './documents'; + +export const createIndexOverviewContent = ( + core: CoreStart, + services: ServerlessSearchPluginStartDependencies +): IndexDetailsTab => { + return { + id: 'documents', + name: ( + + ), + order: 11, + renderTabContent: ({ index }) => { + const queryClient = new QueryClient(); + return ( + + + + + + + ); + }, + }; +}; diff --git a/x-pack/plugins/serverless_search/public/application/hooks/api/use_index_documents.tsx b/x-pack/plugins/serverless_search/public/application/hooks/api/use_index_documents.tsx new file mode 100644 index 0000000000000..7c95376b295d8 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/hooks/api/use_index_documents.tsx @@ -0,0 +1,47 @@ +/* + * 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 { Pagination } from '@elastic/eui'; +import { SearchHit } from '@kbn/es-types'; +import { pageToPagination, Paginate } from '@kbn/search-index-documents'; +import { useQuery } from '@tanstack/react-query'; +import { useKibanaServices } from '../use_kibana'; + +interface IndexDocuments { + meta: Pagination; + results: Paginate; +} +const DEFAULT_PAGINATION = { + from: 0, + has_more_hits_than_total: false, + size: 10, + total: 0, +}; +export const useIndexDocumentSearch = ( + indexName: string, + pagination: Omit, + searchQuery?: string +) => { + const { http } = useKibanaServices(); + const response = useQuery({ + queryKey: ['fetchIndexDocuments', pagination, searchQuery], + queryFn: async () => + http.post(`/internal/serverless_search/indices/${indexName}/search`, { + body: JSON.stringify({ + searchQuery, + }), + query: { + page: pagination.pageIndex, + size: pagination.pageSize, + }, + }), + }); + return { + results: response?.data?.results, + meta: pageToPagination(response?.data?.results?._meta?.page ?? DEFAULT_PAGINATION), + }; +}; diff --git a/x-pack/plugins/serverless_search/public/application/hooks/api/use_index_mappings.tsx b/x-pack/plugins/serverless_search/public/application/hooks/api/use_index_mappings.tsx new file mode 100644 index 0000000000000..28ee739bc6700 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/hooks/api/use_index_mappings.tsx @@ -0,0 +1,21 @@ +/* + * 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 { IndicesGetMappingIndexMappingRecord } from '@elastic/elasticsearch/lib/api/types'; +import { useQuery } from '@tanstack/react-query'; +import { useKibanaServices } from '../use_kibana'; + +export const useIndexMappings = (indexName: string) => { + const { http } = useKibanaServices(); + return useQuery({ + queryKey: ['fetchIndexMappings'], + queryFn: async () => + http.fetch( + `/internal/serverless_search/mappings/${indexName}` + ), + }); +}; diff --git a/x-pack/plugins/serverless_search/public/plugin.ts b/x-pack/plugins/serverless_search/public/plugin.ts index 4203925650385..f82af8390c9fc 100644 --- a/x-pack/plugins/serverless_search/public/plugin.ts +++ b/x-pack/plugins/serverless_search/public/plugin.ts @@ -24,6 +24,7 @@ import { ServerlessSearchPluginStart, ServerlessSearchPluginStartDependencies, } from './types'; +import { createIndexOverviewContent } from './application/components/index_documents/documents_tab'; export class ServerlessSearchPlugin implements @@ -80,14 +81,14 @@ export class ServerlessSearchPlugin return await renderApp(element, coreStart, { history, ...services }); }, }); - return {}; } public start( core: CoreStart, - { serverless, management, cloud, indexManagement }: ServerlessSearchPluginStartDependencies + services: ServerlessSearchPluginStartDependencies ): ServerlessSearchPluginStart { + const { serverless, management, cloud, indexManagement } = services; serverless.setProjectHome('/app/elasticsearch'); serverless.setSideNavComponent(createComponent(core, { serverless, cloud })); management.setIsSidebarEnabled(false); @@ -96,6 +97,10 @@ export class ServerlessSearchPlugin hideLinksTo: [appIds.MAINTENANCE_WINDOWS], }); indexManagement?.extensionsService.setIndexMappingsContent(createIndexMappingsContent(core)); + indexManagement?.extensionsService.addIndexDetailsTab( + createIndexOverviewContent(core, services) + ); + return {}; } diff --git a/x-pack/plugins/serverless_search/server/plugin.ts b/x-pack/plugins/serverless_search/server/plugin.ts index 6e6a90735ee23..e533ae6023380 100644 --- a/x-pack/plugins/serverless_search/server/plugin.ts +++ b/x-pack/plugins/serverless_search/server/plugin.ts @@ -28,6 +28,7 @@ import type { } from './types'; import { registerConnectorsRoutes } from './routes/connectors_routes'; import { registerTelemetryUsageCollector } from './collectors/connectors/telemetry'; +import { registerMappingRoutes } from './routes/mapping_routes'; export interface RouteDependencies { http: CoreSetup['http']; @@ -93,6 +94,7 @@ export class ServerlessSearchPlugin registerApiKeyRoutes(dependencies); registerConnectorsRoutes(dependencies); registerIndicesRoutes(dependencies); + registerMappingRoutes(dependencies); }); if (usageCollection) { diff --git a/x-pack/plugins/serverless_search/server/routes/indices_routes.ts b/x-pack/plugins/serverless_search/server/routes/indices_routes.ts index 9cac99f30ab31..7f165f13218f1 100644 --- a/x-pack/plugins/serverless_search/server/routes/indices_routes.ts +++ b/x-pack/plugins/serverless_search/server/routes/indices_routes.ts @@ -8,6 +8,8 @@ import { IndicesIndexState } from '@elastic/elasticsearch/lib/api/types'; import { schema } from '@kbn/config-schema'; +import { fetchSearchResults } from '@kbn/search-index-documents/lib'; +import { DEFAULT_DOCS_PER_PAGE } from '@kbn/search-index-documents/types'; import { fetchIndices } from '../lib/indices/fetch_indices'; import { RouteDependencies } from '../plugin'; @@ -72,6 +74,45 @@ export const registerIndicesRoutes = ({ router, security }: RouteDependencies) = }); } ); + + router.post( + { + path: '/internal/serverless_search/indices/{index_name}/search', + validate: { + body: schema.object({ + searchQuery: schema.string({ + defaultValue: '', + }), + }), + params: schema.object({ + index_name: schema.string(), + }), + query: schema.object({ + page: schema.number({ defaultValue: 0, min: 0 }), + size: schema.number({ + defaultValue: DEFAULT_DOCS_PER_PAGE, + min: 0, + }), + }), + }, + }, + async (context, request, response) => { + const client = (await context.core).elasticsearch.client.asCurrentUser; + const indexName = decodeURIComponent(request.params.index_name); + const searchQuery = request.body.searchQuery; + const { page = 0, size = DEFAULT_DOCS_PER_PAGE } = request.query; + const from = page * size; + + const searchResults = await fetchSearchResults(client, indexName, searchQuery, from, size); + + return response.ok({ + body: { + results: searchResults, + }, + headers: { 'content-type': 'application/json' }, + }); + } + ); }; function isHidden(index?: IndicesIndexState): boolean { diff --git a/x-pack/plugins/serverless_search/server/routes/mapping_routes.ts b/x-pack/plugins/serverless_search/server/routes/mapping_routes.ts new file mode 100644 index 0000000000000..bb6e22a1bd8fe --- /dev/null +++ b/x-pack/plugins/serverless_search/server/routes/mapping_routes.ts @@ -0,0 +1,33 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { RouteDependencies } from '../plugin'; + +export const registerMappingRoutes = ({ router }: RouteDependencies) => { + router.get( + { + path: '/internal/serverless_search/mappings/{index_name}', + validate: { + params: schema.object({ + index_name: schema.string(), + }), + }, + }, + async (context, request, response) => { + const { client } = (await context.core).elasticsearch; + const mapping = await client.asCurrentUser.indices.getMapping({ + expand_wildcards: ['open'], + index: request.params.index_name, + }); + return response.ok({ + body: mapping[request.params.index_name], + headers: { 'content-type': 'application/json' }, + }); + } + ); +}; diff --git a/x-pack/plugins/serverless_search/tsconfig.json b/x-pack/plugins/serverless_search/tsconfig.json index 6e00a0f988c2d..e8cad3756b974 100644 --- a/x-pack/plugins/serverless_search/tsconfig.json +++ b/x-pack/plugins/serverless_search/tsconfig.json @@ -30,6 +30,7 @@ "@kbn/management-cards-navigation", "@kbn/core-elasticsearch-server", "@kbn/search-api-panels", + "@kbn/search-index-documents", "@kbn/serverless-search-settings", "@kbn/core-lifecycle-browser", "@kbn/react-kibana-context-theme", @@ -39,5 +40,6 @@ "@kbn/kibana-utils-plugin", "@kbn/index-management-plugin", "@kbn/usage-collection-plugin", + "@kbn/es-types", ] }