From 50e9a0217ae7db27f5bee8cc4c2d1e2205a58de2 Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Tue, 16 Jul 2024 19:57:50 +0000 Subject: [PATCH 01/11] search_homepage: configure index stats access Added a config value to set if getting index stats is available. for ES3 we will not be able to fetch index stats so we need an easy way to disable this in routes that fetch index data. --- config/serverless.es.yml | 4 +++- x-pack/plugins/search_homepage/server/config.ts | 1 + x-pack/plugins/search_homepage/server/plugin.ts | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/config/serverless.es.yml b/config/serverless.es.yml index 5dd773912c3a0..6f7a30cb53fb1 100644 --- a/config/serverless.es.yml +++ b/config/serverless.es.yml @@ -71,7 +71,9 @@ xpack.searchInferenceEndpoints.ui.enabled: false xpack.search.notebooks.catalog.url: https://elastic-enterprise-search.s3.us-east-2.amazonaws.com/serverless/catalog.json # Search Homepage -xpack.search.homepage.ui.enabled: true +xpack.search.homepage: + enableIndexStats: false + ui.enabled: true # Semantic text UI xpack.index_management.dev.enableSemanticText: false diff --git a/x-pack/plugins/search_homepage/server/config.ts b/x-pack/plugins/search_homepage/server/config.ts index 3e068a719f046..a3041c167a896 100644 --- a/x-pack/plugins/search_homepage/server/config.ts +++ b/x-pack/plugins/search_homepage/server/config.ts @@ -12,6 +12,7 @@ export * from './types'; const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), + enableIndexStats: schema.boolean({ defaultValue: true }), ui: schema.object({ enabled: schema.boolean({ defaultValue: false }), }), diff --git a/x-pack/plugins/search_homepage/server/plugin.ts b/x-pack/plugins/search_homepage/server/plugin.ts index f446ba4e41fd3..e516bb613a7dd 100644 --- a/x-pack/plugins/search_homepage/server/plugin.ts +++ b/x-pack/plugins/search_homepage/server/plugin.ts @@ -12,9 +12,11 @@ export class SearchHomepagePlugin implements Plugin { private readonly logger: Logger; + private readonly config: SearchHomepageConfig; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); + this.config = initializerContext.config.get(); } public setup(core: CoreSetup<{}, SearchHomepagePluginStart>) { From d5a61a55d15c6aa88616550077b116e91ad6199a Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Tue, 16 Jul 2024 21:53:59 +0000 Subject: [PATCH 02/11] search_homepage: get indices route Added the get indices route with support for stack and serverless. --- .../plugins/search_homepage/common/routes.ts | 10 + .../plugins/search_homepage/common/types.ts | 23 ++ .../search_homepage/server/__mocks__/index.ts | 9 + .../server/__mocks__/indices.ts | 200 ++++++++++ .../__mocks__/routeDependencies.mock.ts | 10 + .../server/__mocks__/router.mock.ts | 121 ++++++ .../search_homepage/server/constants.ts | 10 + .../server/lib/fetch_indices.test.ts | 343 ++++++++++++++++++ .../server/lib/fetch_indices.ts | 115 ++++++ .../plugins/search_homepage/server/plugin.ts | 13 + .../search_homepage/server/routes.test.ts | 104 ++++++ .../plugins/search_homepage/server/routes.ts | 56 +++ .../server/utils/error_handler.ts | 34 ++ 13 files changed, 1048 insertions(+) create mode 100644 x-pack/plugins/search_homepage/common/routes.ts create mode 100644 x-pack/plugins/search_homepage/common/types.ts create mode 100644 x-pack/plugins/search_homepage/server/__mocks__/index.ts create mode 100644 x-pack/plugins/search_homepage/server/__mocks__/indices.ts create mode 100644 x-pack/plugins/search_homepage/server/__mocks__/routeDependencies.mock.ts create mode 100644 x-pack/plugins/search_homepage/server/__mocks__/router.mock.ts create mode 100644 x-pack/plugins/search_homepage/server/constants.ts create mode 100644 x-pack/plugins/search_homepage/server/lib/fetch_indices.test.ts create mode 100644 x-pack/plugins/search_homepage/server/lib/fetch_indices.ts create mode 100644 x-pack/plugins/search_homepage/server/routes.test.ts create mode 100644 x-pack/plugins/search_homepage/server/routes.ts create mode 100644 x-pack/plugins/search_homepage/server/utils/error_handler.ts diff --git a/x-pack/plugins/search_homepage/common/routes.ts b/x-pack/plugins/search_homepage/common/routes.ts new file mode 100644 index 0000000000000..9400a70a0c8d2 --- /dev/null +++ b/x-pack/plugins/search_homepage/common/routes.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum APIRoutes { + GET_INDICES = '/internal/search_homepage/indices', +} diff --git a/x-pack/plugins/search_homepage/common/types.ts b/x-pack/plugins/search_homepage/common/types.ts new file mode 100644 index 0000000000000..f446ab3ecff01 --- /dev/null +++ b/x-pack/plugins/search_homepage/common/types.ts @@ -0,0 +1,23 @@ +/* + * 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 type { + HealthStatus, + IndicesStatsIndexMetadataState, +} from '@elastic/elasticsearch/lib/api/types'; + +export interface GetIndicesIndexData { + aliases: string[]; + count: number; // Elasticsearch _count + health?: HealthStatus; + name: string; + status?: IndicesStatsIndexMetadataState; +} + +export interface GetIndicesResponse { + indices: GetIndicesIndexData[]; +} diff --git a/x-pack/plugins/search_homepage/server/__mocks__/index.ts b/x-pack/plugins/search_homepage/server/__mocks__/index.ts new file mode 100644 index 0000000000000..6aa4146428275 --- /dev/null +++ b/x-pack/plugins/search_homepage/server/__mocks__/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export * from './routeDependencies.mock'; +export { MockRouter } from './router.mock'; diff --git a/x-pack/plugins/search_homepage/server/__mocks__/indices.ts b/x-pack/plugins/search_homepage/server/__mocks__/indices.ts new file mode 100644 index 0000000000000..c8ed0fa3bd5da --- /dev/null +++ b/x-pack/plugins/search_homepage/server/__mocks__/indices.ts @@ -0,0 +1,200 @@ +/* + * 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 { IndicesGetResponse, IndicesStatsResponse } from '@elastic/elasticsearch/lib/api/types'; + +export const MOCK_GET_INDICES_RESPONSES: Record = { + regular: { + 'unit-test-index': { + aliases: {}, + settings: {}, + }, + }, + withAlias: { + 'unit-test-index': { + aliases: { + 'test-alias': {}, + }, + settings: {}, + }, + }, + withHiddenAlias: { + 'unit-test-index': { + aliases: { + 'test-alias': { + is_hidden: true, + }, + }, + settings: {}, + }, + }, + hiddenIndex: { + 'test-hidden': { + aliases: {}, + settings: { + index: { + hidden: true, + }, + }, + }, + }, + closedIndex: { + 'test-hidden': { + aliases: {}, + settings: { + index: { + verified_before_close: true, + }, + }, + }, + }, + manyResults: { + 'unit-test-index-001': { + aliases: {}, + settings: {}, + }, + 'unit-test-index-002': { + aliases: {}, + settings: {}, + }, + 'unit-test-index-003': { + aliases: {}, + settings: {}, + }, + 'unit-test-index-004': { + aliases: {}, + settings: {}, + }, + 'unit-test-index-005': { + aliases: {}, + settings: {}, + }, + 'unit-test-index-006': { + aliases: {}, + settings: {}, + }, + 'unit-test-index-007': { + aliases: {}, + settings: {}, + }, + 'unit-test-index-008': { + aliases: {}, + settings: {}, + }, + 'unit-test-index-009': { + aliases: {}, + settings: {}, + }, + }, +}; +export const MOCK_INDICES_STATS_RESPONSES: Record = { + regular: { + _shards: { + total: 1, + successful: 1, + failed: 0, + }, + _all: {}, + indices: { + 'unit-test-index': { + health: 'green', + status: 'open', + total: { + docs: { + count: 100, + deleted: 0, + }, + store: { + reserved_in_bytes: 0, + size_in_bytes: 108000, + }, + }, + uuid: '83a81e7e-5955-4255-b008-5d6961203f57', + }, + }, + }, + manyResults: { + _shards: { + total: 1, + successful: 1, + failed: 0, + }, + _all: {}, + indices: { + 'unit-test-index-001': { + health: 'green', + status: 'open', + total: { + docs: { + count: 100, + deleted: 0, + }, + store: { + reserved_in_bytes: 0, + size_in_bytes: 108000, + }, + }, + }, + 'unit-test-index-002': { + health: 'yellow', + status: 'open', + total: { + docs: { + count: 100, + deleted: 0, + }, + store: { + reserved_in_bytes: 0, + size_in_bytes: 108000, + }, + }, + }, + 'unit-test-index-003': { + health: 'green', + status: 'open', + total: { + docs: { + count: 100, + deleted: 0, + }, + store: { + reserved_in_bytes: 0, + size_in_bytes: 108000, + }, + }, + }, + 'unit-test-index-004': { + health: 'green', + status: 'open', + total: { + docs: { + count: 100, + deleted: 0, + }, + store: { + reserved_in_bytes: 0, + size_in_bytes: 108000, + }, + }, + }, + 'unit-test-index-005': { + health: 'RED', + status: 'open', + total: { + docs: { + count: 100, + deleted: 0, + }, + store: { + reserved_in_bytes: 0, + size_in_bytes: 108000, + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/search_homepage/server/__mocks__/routeDependencies.mock.ts b/x-pack/plugins/search_homepage/server/__mocks__/routeDependencies.mock.ts new file mode 100644 index 0000000000000..694f9c0ec0209 --- /dev/null +++ b/x-pack/plugins/search_homepage/server/__mocks__/routeDependencies.mock.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from '@kbn/core/server/mocks'; + +export const mockLogger = loggingSystemMock.createLogger().get(); diff --git a/x-pack/plugins/search_homepage/server/__mocks__/router.mock.ts b/x-pack/plugins/search_homepage/server/__mocks__/router.mock.ts new file mode 100644 index 0000000000000..2598d58077992 --- /dev/null +++ b/x-pack/plugins/search_homepage/server/__mocks__/router.mock.ts @@ -0,0 +1,121 @@ +/* + * 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 { + IRouter, + KibanaRequest, + RequestHandlerContext, + RouteValidatorConfig, +} from '@kbn/core/server'; +import { httpServiceMock, httpServerMock } from '@kbn/core/server/mocks'; + +/** + * Test helper that mocks Kibana's router and DRYs out various helper (callRoute, schema validation) + */ + +type MethodType = 'get' | 'post' | 'put' | 'patch' | 'delete'; +type PayloadType = 'params' | 'query' | 'body'; + +interface IMockRouter { + method: MethodType; + path: string; + context?: jest.Mocked; +} +interface IMockRouterRequest { + body?: object; + query?: object; + params?: object; +} +type MockRouterRequest = KibanaRequest | IMockRouterRequest; + +export class MockRouter { + public router!: jest.Mocked; + public method: MethodType; + public path: string; + public context: jest.Mocked; + public payload?: PayloadType; + public response = httpServerMock.createResponseFactory(); + + constructor({ method, path, context = {} as jest.Mocked }: IMockRouter) { + this.createRouter(); + this.method = method; + this.path = path; + this.context = context; + } + + public createRouter = () => { + this.router = httpServiceMock.createRouter(); + }; + + public callRoute = async (request: MockRouterRequest) => { + const route = this.findRouteRegistration(); + const [, handler] = route; + await handler(this.context, httpServerMock.createKibanaRequest(request as any), this.response); + }; + + /** + * Schema validation helpers + */ + + public validateRoute = (request: MockRouterRequest) => { + const route = this.findRouteRegistration(); + const [config] = route; + const validate = config.validate as RouteValidatorConfig<{}, {}, {}>; + const payloads = Object.keys(request) as PayloadType[]; + + payloads.forEach((payload: PayloadType) => { + const payloadValidation = validate[payload] as { validate(request: KibanaRequest): void }; + const payloadRequest = request[payload] as KibanaRequest; + + payloadValidation.validate(payloadRequest); + }); + }; + + public shouldValidate = (request: MockRouterRequest) => { + expect(() => this.validateRoute(request)).not.toThrow(); + }; + + public shouldThrow = (request: MockRouterRequest) => { + expect(() => this.validateRoute(request)).toThrow(); + }; + + private findRouteRegistration = () => { + const routerCalls = this.router[this.method].mock.calls as any[]; + if (!routerCalls.length) throw new Error('No routes registered.'); + + const route = routerCalls.find(([router]: any) => router.path === this.path); + if (!route) throw new Error('No matching registered routes found - check method/path keys'); + + return route; + }; +} + +/** + * Example usage: + */ +// const mockRouter = new MockRouter({ +// method: 'get', +// path: '/internal/app_search/test', +// }); +// +// beforeEach(() => { +// jest.clearAllMocks(); +// mockRouter.createRouter(); +// +// registerExampleRoute({ router: mockRouter.router, ...dependencies }); // Whatever other dependencies the route needs +// }); + +// it('hits the endpoint successfully', async () => { +// await mockRouter.callRoute({ body: { foo: 'bar' } }); +// +// expect(mockRouter.response.ok).toHaveBeenCalled(); +// }); + +// it('validates', () => { +// const request = { body: { foo: 'bar' } }; +// mockRouter.shouldValidate(request); +// }); diff --git a/x-pack/plugins/search_homepage/server/constants.ts b/x-pack/plugins/search_homepage/server/constants.ts new file mode 100644 index 0000000000000..80662b9f4fbf1 --- /dev/null +++ b/x-pack/plugins/search_homepage/server/constants.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const FETCH_INDICES_DEFAULT_SIZE = 5; + +export const DEFAULT_JSON_HEADERS = { 'content-type': 'application/json' }; diff --git a/x-pack/plugins/search_homepage/server/lib/fetch_indices.test.ts b/x-pack/plugins/search_homepage/server/lib/fetch_indices.test.ts new file mode 100644 index 0000000000000..b3d9735032a03 --- /dev/null +++ b/x-pack/plugins/search_homepage/server/lib/fetch_indices.test.ts @@ -0,0 +1,343 @@ +/* + * 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 type { ElasticsearchClient } from '@kbn/core/server'; + +import { mockLogger } from '../__mocks__'; +import { MOCK_GET_INDICES_RESPONSES, MOCK_INDICES_STATS_RESPONSES } from '../__mocks__/indices'; + +import { fetchIndices } from './fetch_indices'; + +describe('fetch indices lib', () => { + const mockClient = { + count: jest.fn(), + indices: { + get: jest.fn(), + stats: jest.fn(), + }, + }; + const client = mockClient as unknown as ElasticsearchClient; + + beforeEach(() => { + jest.clearAllMocks(); + mockClient.count.mockResolvedValue({ count: 100 }); + }); + + it('should return indices without aliases', async () => { + mockClient.indices.get.mockResolvedValue(MOCK_GET_INDICES_RESPONSES.regular); + mockClient.indices.stats.mockResolvedValue(MOCK_INDICES_STATS_RESPONSES.regular); + + await expect( + fetchIndices(undefined, 5, { client, hasIndexStats: true, logger: mockLogger }) + ).resolves.toStrictEqual({ + indices: [ + { + aliases: [], + count: 100, + health: 'green', + name: 'unit-test-index', + status: 'open', + }, + ], + }); + + expect(mockClient.indices.get).toHaveBeenCalledTimes(1); + expect(mockClient.indices.get).toHaveBeenCalledWith({ + expand_wildcards: ['open'], + features: ['aliases', 'settings'], + filter_path: [ + '*.aliases', + '*.settings.index.hidden', + '*.settings.index.verified_before_close', + ], + index: '*', + }); + expect(mockClient.indices.stats).toHaveBeenCalledTimes(1); + expect(mockClient.indices.stats).toHaveBeenCalledWith({ + index: ['unit-test-index'], + metric: ['docs', 'store'], + }); + expect(mockClient.count).toHaveBeenCalledTimes(1); + expect(mockClient.count).toHaveBeenCalledWith({ index: 'unit-test-index' }); + }); + it('should return indices stats when enabled', async () => { + mockClient.indices.get.mockResolvedValue(MOCK_GET_INDICES_RESPONSES.manyResults); + mockClient.indices.stats.mockResolvedValue(MOCK_INDICES_STATS_RESPONSES.manyResults); + + await expect( + fetchIndices(undefined, 5, { client, hasIndexStats: true, logger: mockLogger }) + ).resolves.toStrictEqual({ + indices: [ + { + aliases: [], + count: 100, + health: 'green', + name: 'unit-test-index-001', + status: 'open', + }, + { + aliases: [], + count: 100, + health: 'yellow', + name: 'unit-test-index-002', + status: 'open', + }, + { + aliases: [], + count: 100, + health: 'green', + name: 'unit-test-index-003', + status: 'open', + }, + { + aliases: [], + count: 100, + health: 'green', + name: 'unit-test-index-004', + status: 'open', + }, + { + aliases: [], + count: 100, + health: 'RED', + name: 'unit-test-index-005', + status: 'open', + }, + ], + }); + + expect(mockClient.indices.get).toHaveBeenCalledTimes(1); + expect(mockClient.indices.stats).toHaveBeenCalledTimes(1); + expect(mockClient.indices.stats).toHaveBeenCalledWith({ + index: [ + 'unit-test-index-001', + 'unit-test-index-002', + 'unit-test-index-003', + 'unit-test-index-004', + 'unit-test-index-005', + ], + metric: ['docs', 'store'], + }); + }); + it('should not return indices stats when disabled', async () => { + mockClient.indices.get.mockResolvedValue(MOCK_GET_INDICES_RESPONSES.regular); + + await expect( + fetchIndices(undefined, 5, { client, hasIndexStats: false, logger: mockLogger }) + ).resolves.toStrictEqual({ + indices: [ + { + aliases: [], + count: 100, + name: 'unit-test-index', + }, + ], + }); + + expect(mockClient.indices.stats).toHaveBeenCalledTimes(0); + }); + it('should return indices with aliases', async () => { + mockClient.indices.get.mockResolvedValue(MOCK_GET_INDICES_RESPONSES.withAlias); + + await expect( + fetchIndices(undefined, 5, { client, hasIndexStats: false, logger: mockLogger }) + ).resolves.toStrictEqual({ + indices: [ + { + aliases: ['test-alias'], + count: 100, + name: 'unit-test-index', + }, + ], + }); + }); + it('should not return indices with hidden aliases', async () => { + mockClient.indices.get.mockResolvedValue(MOCK_GET_INDICES_RESPONSES.withHiddenAlias); + + await expect( + fetchIndices(undefined, 5, { client, hasIndexStats: false, logger: mockLogger }) + ).resolves.toStrictEqual({ + indices: [ + { + aliases: [], + count: 100, + name: 'unit-test-index', + }, + ], + }); + }); + it('should return indices counts', async () => { + mockClient.indices.get.mockResolvedValue(MOCK_GET_INDICES_RESPONSES.manyResults); + + await expect( + fetchIndices(undefined, 5, { client, hasIndexStats: false, logger: mockLogger }) + ).resolves.toStrictEqual({ + indices: [ + { + aliases: [], + count: 100, + name: 'unit-test-index-001', + }, + { + aliases: [], + count: 100, + name: 'unit-test-index-002', + }, + { + aliases: [], + count: 100, + name: 'unit-test-index-003', + }, + { + aliases: [], + count: 100, + name: 'unit-test-index-004', + }, + { + aliases: [], + count: 100, + name: 'unit-test-index-005', + }, + ], + }); + + expect(mockClient.indices.get).toHaveBeenCalledTimes(1); + expect(mockClient.count).toHaveBeenCalledTimes(5); + expect(mockClient.count.mock.calls).toEqual([ + [{ index: 'unit-test-index-001' }], + [{ index: 'unit-test-index-002' }], + [{ index: 'unit-test-index-003' }], + [{ index: 'unit-test-index-004' }], + [{ index: 'unit-test-index-005' }], + ]); + }); + it('should use search query when given', async () => { + mockClient.indices.get.mockResolvedValue(MOCK_GET_INDICES_RESPONSES.regular); + + await expect( + fetchIndices('test', 5, { client, hasIndexStats: false, logger: mockLogger }) + ).resolves.toStrictEqual({ + indices: [ + { + aliases: [], + count: 100, + name: 'unit-test-index', + }, + ], + }); + + expect(mockClient.indices.get).toHaveBeenCalledTimes(1); + expect(mockClient.indices.get).toHaveBeenCalledWith({ + expand_wildcards: ['open'], + features: ['aliases', 'settings'], + filter_path: [ + '*.aliases', + '*.settings.index.hidden', + '*.settings.index.verified_before_close', + ], + index: '*test*', + }); + }); + it('should exclude hidden indices', async () => { + mockClient.indices.get.mockResolvedValue({ + ...MOCK_GET_INDICES_RESPONSES.regular, + ...MOCK_GET_INDICES_RESPONSES.hiddenIndex, + }); + + await expect( + fetchIndices('test', 5, { client, hasIndexStats: false, logger: mockLogger }) + ).resolves.toStrictEqual({ + indices: [ + { + aliases: [], + count: 100, + name: 'unit-test-index', + }, + ], + }); + }); + it('should exclude closed indices', async () => { + mockClient.indices.get.mockResolvedValue({ + ...MOCK_GET_INDICES_RESPONSES.regular, + ...MOCK_GET_INDICES_RESPONSES.closedIndex, + }); + + await expect( + fetchIndices('test', 5, { client, hasIndexStats: false, logger: mockLogger }) + ).resolves.toStrictEqual({ + indices: [ + { + aliases: [], + count: 100, + name: 'unit-test-index', + }, + ], + }); + }); + it('should handle index count errors', async () => { + mockClient.indices.get.mockResolvedValue(MOCK_GET_INDICES_RESPONSES.manyResults); + mockClient.count.mockImplementation(({ index }) => { + switch (index) { + case 'unit-test-index-002': + return Promise.reject(new Error('Boom!!!')); + default: + return Promise.resolve({ count: 100 }); + } + }); + + await expect( + fetchIndices(undefined, 5, { client, hasIndexStats: false, logger: mockLogger }) + ).resolves.toStrictEqual({ + indices: [ + { + aliases: [], + count: 100, + name: 'unit-test-index-001', + }, + { + aliases: [], + count: 0, + name: 'unit-test-index-002', + }, + { + aliases: [], + count: 100, + name: 'unit-test-index-003', + }, + { + aliases: [], + count: 100, + name: 'unit-test-index-004', + }, + { + aliases: [], + count: 100, + name: 'unit-test-index-005', + }, + ], + }); + + expect(mockClient.count).toHaveBeenCalledTimes(5); + }); + it('should throw if get indices fails', async () => { + const expectedError = new Error('Oh No!!'); + mockClient.indices.get.mockRejectedValue(expectedError); + + await expect( + fetchIndices(undefined, 5, { client, hasIndexStats: false, logger: mockLogger }) + ).rejects.toBe(expectedError); + }); + it('should throw if get stats fails', async () => { + const expectedError = new Error('Oh No!!'); + mockClient.indices.get.mockResolvedValue(MOCK_GET_INDICES_RESPONSES.regular); + mockClient.indices.stats.mockRejectedValue(expectedError); + + await expect( + fetchIndices(undefined, 5, { client, hasIndexStats: true, logger: mockLogger }) + ).rejects.toBe(expectedError); + }); +}); diff --git a/x-pack/plugins/search_homepage/server/lib/fetch_indices.ts b/x-pack/plugins/search_homepage/server/lib/fetch_indices.ts new file mode 100644 index 0000000000000..42760a6e55ede --- /dev/null +++ b/x-pack/plugins/search_homepage/server/lib/fetch_indices.ts @@ -0,0 +1,115 @@ +/* + * 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 { IndicesIndexState } from '@elastic/elasticsearch/lib/api/types'; +import { ElasticsearchClient } from '@kbn/core/server'; +import type { Logger } from '@kbn/logging'; + +import { GetIndicesResponse } from '../../common/types'; + +interface FetchIndicesOptions { + client: ElasticsearchClient; + hasIndexStats: boolean; + logger: Logger; +} + +export async function fetchIndices( + searchQuery: string | undefined, + size: number, + { client, hasIndexStats, logger }: FetchIndicesOptions +): Promise { + const indexPattern = searchQuery ? `*${searchQuery}*` : '*'; + const allIndexMatches = await client.indices.get({ + expand_wildcards: ['open'], + // for better performance only compute aliases and settings of indices but not mappings + features: ['aliases', 'settings'], + // only get specified index properties from ES to keep the response under 536MB + // node.js string length limit: https://github.com/nodejs/node/issues/33960 + filter_path: ['*.aliases', '*.settings.index.hidden', '*.settings.index.verified_before_close'], + index: indexPattern, + }); + + let baseIndicesData = Object.entries(allIndexMatches) + .filter(([_, indexState]) => !isHidden(indexState) && !isClosed(indexState)) + .map(([indexName, indexState]) => ({ + name: indexName, + aliases: getAliasNames(indexState), + })); + if (baseIndicesData.length === 0) return { indices: [] }; + baseIndicesData = baseIndicesData.slice(0, size); + + const [indexCounts, indexStats] = await Promise.all([ + fetchIndexCounts(client, logger, baseIndicesData), + hasIndexStats ? fetchIndexStats(client, logger, baseIndicesData) : Promise.resolve({}), + ]); + const indices = baseIndicesData.map(({ name, aliases }) => ({ + ...(hasIndexStats + ? { + health: indexStats?.[name]?.health, + status: indexStats?.[name]?.status, + } + : {}), + aliases, + count: indexCounts[name] ?? 0, + name, + })); + + return { indices }; +} + +async function fetchIndexCounts( + client: ElasticsearchClient, + logger: Logger, + indices: Array<{ name: string }> +) { + const countPromises = indices.map(async ({ name }) => { + try { + const { count } = await client.count({ index: name }); + return { name, count }; + } catch { + logger.warn(`Failed to get _count for index "${name}"`); + // we don't want to error out the whole API call if one index breaks (eg: doesn't exist or is closed) + return { name, count: 0 }; + } + }); + + const indexCounts = await Promise.all(countPromises); + return indexCounts.reduce((acc, current) => { + acc[current.name] = current.count; + return acc; + }, {} as Record); +} +async function fetchIndexStats( + client: ElasticsearchClient, + _logger: Logger, + indices: Array<{ name: string }> +) { + const indexNames = indices.map(({ name }) => name); + const { indices: indicesStats = {} } = await client.indices.stats({ + index: indexNames, + metric: ['docs', 'store'], + }); + + return indicesStats; +} + +function isHidden(index: IndicesIndexState): boolean { + return index.settings?.index?.hidden === true || index.settings?.index?.hidden === 'true'; +} +function isClosed(index: IndicesIndexState): boolean { + return ( + index.settings?.index?.verified_before_close === true || + index.settings?.index?.verified_before_close === 'true' + ); +} + +function getAliasNames(index: IndicesIndexState): string[] { + if (!index.aliases) return []; + return Object.entries(index.aliases) + .filter(([_, alias]) => !alias.is_hidden) + .map(([name, _]) => name); +} diff --git a/x-pack/plugins/search_homepage/server/plugin.ts b/x-pack/plugins/search_homepage/server/plugin.ts index e516bb613a7dd..c2c77f86ae6ee 100644 --- a/x-pack/plugins/search_homepage/server/plugin.ts +++ b/x-pack/plugins/search_homepage/server/plugin.ts @@ -5,7 +5,9 @@ * 2.0. */ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from '@kbn/core/server'; +import { SearchHomepageConfig } from './config'; +import { defineRoutes } from './routes'; import { SearchHomepagePluginSetup, SearchHomepagePluginStart } from './types'; export class SearchHomepagePlugin @@ -21,10 +23,21 @@ export class SearchHomepagePlugin public setup(core: CoreSetup<{}, SearchHomepagePluginStart>) { this.logger.debug('searchHomepage: Setup'); + const router = core.http.createRouter(); + + defineRoutes({ + getStartServices: core.getStartServices, + logger: this.logger, + router, + options: { hasIndexStats: this.config.enableIndexStats }, + }); + return {}; } public start(core: CoreStart) { return {}; } + + public stop() {} } diff --git a/x-pack/plugins/search_homepage/server/routes.test.ts b/x-pack/plugins/search_homepage/server/routes.test.ts new file mode 100644 index 0000000000000..bb7bd4b3ab179 --- /dev/null +++ b/x-pack/plugins/search_homepage/server/routes.test.ts @@ -0,0 +1,104 @@ +/* + * 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. + */ + +jest.mock('./lib/fetch_indices', () => ({ fetchIndices: jest.fn() })); + +import { mockLogger, MockRouter } from './__mocks__'; + +import { RequestHandlerContext } from '@kbn/core/server'; +import { defineRoutes } from './routes'; +import { APIRoutes } from '../common/routes'; +import { DEFAULT_JSON_HEADERS } from './constants'; +import { fetchIndices } from './lib/fetch_indices'; + +describe('Search Homepage routes', () => { + let mockRouter: MockRouter; + const mockClient = { + asCurrentUser: {}, + }; + + const mockCore = { + elasticsearch: { client: mockClient }, + }; + let context: jest.Mocked; + beforeEach(() => { + jest.clearAllMocks(); + + context = { + core: Promise.resolve(mockCore), + } as unknown as jest.Mocked; + }); + + describe('GET - Indices', () => { + beforeEach(() => { + mockRouter = new MockRouter({ + context, + method: 'get', + path: APIRoutes.GET_INDICES, + }); + + defineRoutes({ + logger: mockLogger, + router: mockRouter.router, + options: { + hasIndexStats: true, + getStartServices: jest.fn().mockResolvedValue([{}, {}, {}]), + }, + }); + }); + + it('return indices result', async () => { + (fetchIndices as jest.Mock).mockResolvedValue({ + indices: [{ name: 'test', count: 0, aliases: [] }], + }); + + await mockRouter.callRoute({}); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { + indices: [ + { + aliases: [], + count: 0, + name: 'test', + }, + ], + }, + headers: DEFAULT_JSON_HEADERS, + }); + + expect(fetchIndices as jest.Mock).toHaveBeenCalledWith(undefined, 5, expect.anything()); + }); + + it('uses search query', async () => { + (fetchIndices as jest.Mock).mockResolvedValue({ + indices: [{ name: 'test', count: 0, aliases: [] }], + }); + + await mockRouter.callRoute({ + query: { + search_query: 'testing', + }, + }); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { + indices: [ + { + aliases: [], + count: 0, + name: 'test', + }, + ], + }, + headers: DEFAULT_JSON_HEADERS, + }); + + expect(fetchIndices as jest.Mock).toHaveBeenCalledWith('testing', 5, expect.anything()); + }); + }); +}); diff --git a/x-pack/plugins/search_homepage/server/routes.ts b/x-pack/plugins/search_homepage/server/routes.ts new file mode 100644 index 0000000000000..7d4f98c329f21 --- /dev/null +++ b/x-pack/plugins/search_homepage/server/routes.ts @@ -0,0 +1,56 @@ +/* + * 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 { IRouter, StartServicesAccessor } from '@kbn/core/server'; +import type { Logger } from '@kbn/logging'; + +import { APIRoutes } from '../common/routes'; +import { fetchIndices } from './lib/fetch_indices'; +import { errorHandler } from './utils/error_handler'; +import { DEFAULT_JSON_HEADERS, FETCH_INDICES_DEFAULT_SIZE } from './constants'; + +export function defineRoutes({ + logger, + router, + options: routeOptions, +}: { + logger: Logger; + router: IRouter; + options: { + hasIndexStats: boolean; + getStartServices: StartServicesAccessor<{}, {}>; + }; +}) { + router.get( + { + path: APIRoutes.GET_INDICES, + validate: { + query: schema.object({ + search_query: schema.maybe(schema.string()), + }), + }, + }, + errorHandler(logger, async (context, request, response) => { + const { search_query: searchQuery } = request.query; + const { + client: { asCurrentUser: client }, + } = (await context.core).elasticsearch; + + const body = await fetchIndices(searchQuery, FETCH_INDICES_DEFAULT_SIZE, { + client, + hasIndexStats: routeOptions.hasIndexStats, + logger, + }); + + return response.ok({ + body, + headers: DEFAULT_JSON_HEADERS, + }); + }) + ); +} diff --git a/x-pack/plugins/search_homepage/server/utils/error_handler.ts b/x-pack/plugins/search_homepage/server/utils/error_handler.ts new file mode 100644 index 0000000000000..1e8c3cca3a68f --- /dev/null +++ b/x-pack/plugins/search_homepage/server/utils/error_handler.ts @@ -0,0 +1,34 @@ +/* + * 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 { RequestHandler } from '@kbn/core/server'; +import { i18n } from '@kbn/i18n'; +import type { Logger } from '@kbn/logging'; + +export function errorHandler( + logger: Logger, + requestHandler: RequestHandler +): RequestHandler { + return async (context, request, response) => { + try { + return await requestHandler(context, request, response); + } catch (e) { + logger.error( + i18n.translate('xpack.searchHomepage.routes.unhandledException', { + defaultMessage: + 'An error occurred while resolving request to {requestMethod} {requestUrl}:', + values: { + requestMethod: request.route.method, + requestUrl: request.url.pathname, + }, + }) + ); + logger.error(e); + throw e; + } + }; +} From 38263c03b437b96097ff9c1ccf9ca5e783013f7b Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Wed, 17 Jul 2024 19:37:22 +0000 Subject: [PATCH 03/11] test(search_homepage): add api integration test --- .../search/serverless_search/homepage.ts | 48 +++++++++++++++++++ .../search/serverless_search/index.ts | 1 + 2 files changed, 49 insertions(+) create mode 100644 x-pack/test_serverless/api_integration/test_suites/search/serverless_search/homepage.ts diff --git a/x-pack/test_serverless/api_integration/test_suites/search/serverless_search/homepage.ts b/x-pack/test_serverless/api_integration/test_suites/search/serverless_search/homepage.ts new file mode 100644 index 0000000000000..8c61e2613eb3f --- /dev/null +++ b/x-pack/test_serverless/api_integration/test_suites/search/serverless_search/homepage.ts @@ -0,0 +1,48 @@ +/* + * 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 expect from 'expect'; +import { RoleCredentials } from '../../../../shared/services'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +const API_BASE_PATH = '/internal/search_homepage'; + +export default function ({ getService }: FtrProviderContext) { + const svlCommonApi = getService('svlCommonApi'); + const svlUserManager = getService('svlUserManager'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + let roleAuthc: RoleCredentials; + + describe('Homepage routes', function () { + describe('GET indices', function () { + before(async () => { + roleAuthc = await svlUserManager.createM2mApiKeyWithRoleScope('viewer'); + }); + after(async () => { + await svlUserManager.invalidateM2mApiKeyWithRoleScope(roleAuthc); + }); + it('has route', async () => { + const { body } = await supertestWithoutAuth + .get(`${API_BASE_PATH}/indices`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .expect(200); + + expect(body.indices).toBeDefined(); + expect(Array.isArray(body.indices)).toBe(true); + }); + it('accepts search_query', async () => { + await supertestWithoutAuth + .get(`${API_BASE_PATH}/indices`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .query({ search_query: 'foo' }) + .expect(200); + }); + }); + }); +} diff --git a/x-pack/test_serverless/api_integration/test_suites/search/serverless_search/index.ts b/x-pack/test_serverless/api_integration/test_suites/search/serverless_search/index.ts index dd80cb7f5342d..8c4fea7ea3f84 100644 --- a/x-pack/test_serverless/api_integration/test_suites/search/serverless_search/index.ts +++ b/x-pack/test_serverless/api_integration/test_suites/search/serverless_search/index.ts @@ -12,5 +12,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./api_key')); loadTestFile(require.resolve('./connectors')); loadTestFile(require.resolve('./indices')); + loadTestFile(require.resolve('./homepage')); }); } From 63e76626a83089114eb44604006e300e74f1e9f3 Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Fri, 19 Jul 2024 20:37:32 +0000 Subject: [PATCH 04/11] search_homepage: fix useKibana typing to have correct ent-search services --- x-pack/plugins/enterprise_search/public/index.ts | 6 +++++- x-pack/plugins/enterprise_search/public/plugin.ts | 4 ++++ x-pack/plugins/search_homepage/public/hooks/use_kibana.ts | 4 +++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/index.ts b/x-pack/plugins/enterprise_search/public/index.ts index 8dc84c6934e42..dc8c7bd650935 100644 --- a/x-pack/plugins/enterprise_search/public/index.ts +++ b/x-pack/plugins/enterprise_search/public/index.ts @@ -13,4 +13,8 @@ export const plugin = (initializerContext: PluginInitializerContext) => { return new EnterpriseSearchPlugin(initializerContext); }; -export type { EnterpriseSearchPublicSetup, EnterpriseSearchPublicStart } from './plugin'; +export type { + EnterpriseSearchPublicSetup, + EnterpriseSearchPublicStart, + EnterpriseSearchKibanaServicesContext, +} from './plugin'; diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 496ce1821c0d1..27ad128a74438 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -41,6 +41,7 @@ import { SearchInferenceEndpointsPluginStart } from '@kbn/search-inference-endpo import { SearchPlaygroundPluginStart } from '@kbn/search-playground/public'; import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public'; import { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public'; +import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; import { ANALYTICS_PLUGIN, @@ -110,8 +111,11 @@ export interface PluginsStart { searchInferenceEndpoints?: SearchInferenceEndpointsPluginStart; security?: SecurityPluginStart; share?: SharePluginStart; + usageCollection?: UsageCollectionStart; } +export type EnterpriseSearchKibanaServicesContext = CoreStart & PluginsStart; + export interface ESConfig { elasticsearch_host: string; } diff --git a/x-pack/plugins/search_homepage/public/hooks/use_kibana.ts b/x-pack/plugins/search_homepage/public/hooks/use_kibana.ts index b22c7b4ed9d7f..621a843fca6dd 100644 --- a/x-pack/plugins/search_homepage/public/hooks/use_kibana.ts +++ b/x-pack/plugins/search_homepage/public/hooks/use_kibana.ts @@ -6,6 +6,8 @@ */ import { useKibana as _useKibana } from '@kbn/kibana-react-plugin/public'; +import type { EnterpriseSearchKibanaServicesContext } from '@kbn/enterprise-search-plugin/public'; import { SearchHomepageServicesContext } from '../types'; -export const useKibana = () => _useKibana(); +export const useKibana = () => + _useKibana(); From f9996328101cce60671452316a87dd3f83e4a120 Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Fri, 19 Jul 2024 20:55:55 +0000 Subject: [PATCH 05/11] search_homepage: indices card --- .../search_homepage/public/application.tsx | 15 +- .../search_homepage/public/assets/no_data.png | Bin 0 -> 49160 bytes .../public/components/create_index_modal.tsx | 175 ++++++++++++++++++ .../public/components/homepage_view.tsx | 43 +++++ .../components/indices_card/empty_state.tsx | 76 ++++++++ .../public/components/indices_card/index.tsx | 173 +++++++++++++++++ .../indices_card/index_list_label.tsx | 40 ++++ .../components/indices_card/index_metrics.tsx | 29 +++ .../components/indices_card/indices_card.scss | 10 + .../components/indices_card/indices_list.tsx | 68 +++++++ .../public/components/search_homepage.tsx | 6 +- .../components/search_homepage_body.tsx | 27 ++- .../components/search_homepage_header.tsx | 1 + .../public/components/stack_app.tsx | 13 +- .../search_homepage/public/constants.ts | 14 ++ .../public/hooks/api/use_create_index.ts | 24 +++ .../public/hooks/api/use_indices.ts | 26 +++ .../public/hooks/use_asset_basepath.ts | 14 ++ .../plugins/search_homepage/public/types.ts | 9 +- .../public/utils/get_error_message.ts | 43 +++++ .../public/utils/is_valid_index_name.ts | 21 +++ .../public/utils/query_client.ts | 54 ++++++ 22 files changed, 859 insertions(+), 22 deletions(-) create mode 100644 x-pack/plugins/search_homepage/public/assets/no_data.png create mode 100644 x-pack/plugins/search_homepage/public/components/create_index_modal.tsx create mode 100644 x-pack/plugins/search_homepage/public/components/homepage_view.tsx create mode 100644 x-pack/plugins/search_homepage/public/components/indices_card/empty_state.tsx create mode 100644 x-pack/plugins/search_homepage/public/components/indices_card/index.tsx create mode 100644 x-pack/plugins/search_homepage/public/components/indices_card/index_list_label.tsx create mode 100644 x-pack/plugins/search_homepage/public/components/indices_card/index_metrics.tsx create mode 100644 x-pack/plugins/search_homepage/public/components/indices_card/indices_card.scss create mode 100644 x-pack/plugins/search_homepage/public/components/indices_card/indices_list.tsx create mode 100644 x-pack/plugins/search_homepage/public/constants.ts create mode 100644 x-pack/plugins/search_homepage/public/hooks/api/use_create_index.ts create mode 100644 x-pack/plugins/search_homepage/public/hooks/api/use_indices.ts create mode 100644 x-pack/plugins/search_homepage/public/hooks/use_asset_basepath.ts create mode 100644 x-pack/plugins/search_homepage/public/utils/get_error_message.ts create mode 100644 x-pack/plugins/search_homepage/public/utils/is_valid_index_name.ts create mode 100644 x-pack/plugins/search_homepage/public/utils/query_client.ts diff --git a/x-pack/plugins/search_homepage/public/application.tsx b/x-pack/plugins/search_homepage/public/application.tsx index 5d2a04a97cc63..462d62557d7d5 100644 --- a/x-pack/plugins/search_homepage/public/application.tsx +++ b/x-pack/plugins/search_homepage/public/application.tsx @@ -12,23 +12,28 @@ import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { I18nProvider } from '@kbn/i18n-react'; import { Router } from '@kbn/shared-ux-router'; -import { SearchHomepageServicesContext } from './types'; -import { HomepageRouter } from './router'; +import { QueryClientProvider } from '@tanstack/react-query'; import { UsageTrackerContextProvider } from './contexts/usage_tracker_context'; +import { HomepageRouter } from './router'; +import { SearchHomepageServicesContext } from './types'; +import { initQueryClient } from './utils/query_client'; export const renderApp = async ( core: CoreStart, services: SearchHomepageServicesContext, element: HTMLElement ) => { + const queryClient = initQueryClient(core.notifications.toasts); ReactDOM.render( - - - + + + + + diff --git a/x-pack/plugins/search_homepage/public/assets/no_data.png b/x-pack/plugins/search_homepage/public/assets/no_data.png new file mode 100644 index 0000000000000000000000000000000000000000..6f268f1b0dfa0ada8bb3bd78c737e880ff213c76 GIT binary patch literal 49160 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 void; +} + +export const CreateIndexModal = ({ closeModal }: CreateIndexModalProps) => { + const queryClient = useQueryClient(); + const { notifications } = useKibana().services; + const { mutateAsync: createIndex } = useCreateIndex(); + const [indexName, setIndexName] = useState(''); + const [indexNameError, setIndexNameError] = useState(); + const [isSaving, setIsSaving] = useState(false); + const [createError, setCreateError] = useState(undefined); + + const putCreateIndex = useCallback(async () => { + setIsSaving(true); + try { + await createIndex(indexName); + notifications.toasts.addSuccess( + i18n.translate('xpack.searchHomepage.createIndex.successfullyCreatedIndexMessage', { + defaultMessage: 'Successfully created index: {indexName}', + values: { indexName }, + }), + 'success' + ); + closeModal(); + queryClient.invalidateQueries({ queryKey: [QueryKeys.FetchIndices] }); + } catch (error) { + setCreateError( + getErrorMessage( + error, + i18n.translate('xpack.searchHomepage.createIndex.error.fallbackMessage', { + defaultMessage: 'Unknown error creating index.', + }) + ) + ); + } finally { + setIsSaving(false); + } + }, [createIndex, closeModal, indexName, queryClient, notifications]); + + const onSave = () => { + if (isValidIndexName(indexName)) { + putCreateIndex().catch(() => {}); + } + }; + + const onNameChange = (name: string) => { + setIndexName(name); + if (!isValidIndexName(name)) { + setIndexNameError(INVALID_INDEX_NAME_ERROR); + } else if (indexNameError) { + setIndexNameError(undefined); + } + }; + + return ( + + + + + + + + {createError && ( + <> + + + + + + + + )} + + + onNameChange(e.target.value)} + data-test-subj="createIndexNameFieldText" + /> + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/search_homepage/public/components/homepage_view.tsx b/x-pack/plugins/search_homepage/public/components/homepage_view.tsx new file mode 100644 index 0000000000000..106d19fd1efd6 --- /dev/null +++ b/x-pack/plugins/search_homepage/public/components/homepage_view.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 React, { useCallback, useState } from 'react'; + +import { useKibana } from '../hooks/use_kibana'; +import { SearchHomepageBody } from './search_homepage_body'; +import { SearchHomepageHeader } from './search_homepage_header'; +import { CreateIndexModal } from './create_index_modal'; + +export interface HomepageViewProps { + showEndpointsAPIKeys?: boolean; +} +export const HomepageView = ({ showEndpointsAPIKeys = false }: HomepageViewProps) => { + const { application, share } = useKibana().services; + const [createIndexModalOpen, setCreateIndexModalOpen] = useState(false); + const onCreateIndex = useCallback(async () => { + const createIndexLocator = share?.url.locators.get('CREATE_INDEX_LOCATOR_ID'); + if (createIndexLocator) { + const createIndexUrl = await createIndexLocator.getUrl({}); + application.navigateToUrl(createIndexUrl); + } else { + setCreateIndexModalOpen(true); + } + }, [application, share]); + + return ( + <> + + + {createIndexModalOpen && ( + setCreateIndexModalOpen(false)} /> + )} + + ); +}; diff --git a/x-pack/plugins/search_homepage/public/components/indices_card/empty_state.tsx b/x-pack/plugins/search_homepage/public/components/indices_card/empty_state.tsx new file mode 100644 index 0000000000000..1f0e7b369c64b --- /dev/null +++ b/x-pack/plugins/search_homepage/public/components/indices_card/empty_state.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, { useCallback } from 'react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiImage, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { useAssetBasePath } from '../../hooks/use_asset_basepath'; +import { useUsageTracker } from '../../hooks/use_usage_tracker'; + +export interface IndicesEmptyStateProps { + onCreateIndex: () => void; +} +export const IndicesEmptyState = ({ onCreateIndex }: IndicesEmptyStateProps) => { + const assetBasePath = useAssetBasePath(); + const usageTracker = useUsageTracker(); + const noDataImage = `${assetBasePath}/no_data.png`; + const onAddDataClick = useCallback(() => { + usageTracker.click('empty-indices-add-data'); + onCreateIndex(); + }, [usageTracker, onCreateIndex]); + + return ( + + + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/search_homepage/public/components/indices_card/index.tsx b/x-pack/plugins/search_homepage/public/components/indices_card/index.tsx new file mode 100644 index 0000000000000..85f01f4b6ea16 --- /dev/null +++ b/x-pack/plugins/search_homepage/public/components/indices_card/index.tsx @@ -0,0 +1,173 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { + EuiButton, + EuiCallOut, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { useIndices } from '../../hooks/api/use_indices'; +import { IndicesEmptyState } from './empty_state'; +import { IndicesList } from './indices_list'; +import './indices_card.scss'; +import { getErrorMessage } from '../../utils/get_error_message'; +import { useUsageTracker } from '../../hooks/use_usage_tracker'; + +enum IndicesCardContentView { + Loading, + Error, + Empty, + Data, +} + +export interface IndicesCardProps { + onCreateIndex: () => void; +} + +export const IndicesCard = ({ onCreateIndex }: IndicesCardProps) => { + const usageTracker = useUsageTracker(); + const [searchField, setSearchField] = useState(''); + const [indicesSearchQuery, setIndicesSearchQuery] = useState(''); + const { data, error, isLoading, isFetching } = useIndices(indicesSearchQuery); + + const onSearch = useCallback((value: string) => { + const trimSearch = value.trim(); + setSearchField(trimSearch); + setIndicesSearchQuery(trimSearch); + }, []); + const onChangeSearchField = useCallback( + (e: React.ChangeEvent) => { + const newSearchField = e.target.value; + const runSearch = searchField.length > 0 && newSearchField.length === 0; + setSearchField(newSearchField); + if (runSearch) { + onSearch(newSearchField); + } + }, + [searchField, onSearch] + ); + const onAddDataClick = useCallback(() => { + usageTracker.click('indices-card-add-data'); + onCreateIndex(); + }, [usageTracker, onCreateIndex]); + + const isEmptyData = data && data.indices.length === 0 && indicesSearchQuery.length === 0; + const view = + isLoading && !data + ? IndicesCardContentView.Loading + : error + ? IndicesCardContentView.Error + : isEmptyData + ? IndicesCardContentView.Empty + : IndicesCardContentView.Data; + return ( + + + + + + + + + + + + {view === IndicesCardContentView.Loading ? : null} + {view === IndicesCardContentView.Empty ? ( + + ) : null} + {view === IndicesCardContentView.Error || view === IndicesCardContentView.Data ? ( + <> + + + + + {view === IndicesCardContentView.Error ? ( + + + + ) : null} + {view === IndicesCardContentView.Data ? ( + <> + + + + + + + ) : null} + + ) : null} + + ); +}; + +const Loading = () => ( + + + +); + +const Error = ({ error }: { error: unknown }) => ( + + +

+ {getErrorMessage( + error, + i18n.translate('xpack.searchHomepage.indicesCard.fetchError.fallbackMessage', { + defaultMessage: + 'There was an error fetching indices, check Kibana logs for more information.', + }) + )} +

+
+
+); diff --git a/x-pack/plugins/search_homepage/public/components/indices_card/index_list_label.tsx b/x-pack/plugins/search_homepage/public/components/indices_card/index_list_label.tsx new file mode 100644 index 0000000000000..35f1f8e47f1f5 --- /dev/null +++ b/x-pack/plugins/search_homepage/public/components/indices_card/index_list_label.tsx @@ -0,0 +1,40 @@ +/* + * 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 from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; + +import { GetIndicesIndexData } from '../../../common/types'; + +import { IndexListItemMetrics } from './index_metrics'; + +export interface IndexListLabelProps { + index: GetIndicesIndexData; +} +export const IndexListLabel = ({ index }: IndexListLabelProps) => { + return ( + + + + {index.name} + + + + + + + + + ); +}; diff --git a/x-pack/plugins/search_homepage/public/components/indices_card/index_metrics.tsx b/x-pack/plugins/search_homepage/public/components/indices_card/index_metrics.tsx new file mode 100644 index 0000000000000..a0bf51cd8bb45 --- /dev/null +++ b/x-pack/plugins/search_homepage/public/components/indices_card/index_metrics.tsx @@ -0,0 +1,29 @@ +/* + * 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 from 'react'; +import { FormattedNumber } from '@kbn/i18n-react'; +import { EuiFlexGroup, EuiHealth, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui'; +import { GetIndicesIndexData } from '../../../common/types'; + +export interface IndexListItemMetricsProps { + index: GetIndicesIndexData; +} +export const IndexListItemMetrics = ({ index }: IndexListItemMetricsProps) => { + return ( + + <> + + + + + + + {index.health ? : null} + + ); +}; diff --git a/x-pack/plugins/search_homepage/public/components/indices_card/indices_card.scss b/x-pack/plugins/search_homepage/public/components/indices_card/indices_card.scss new file mode 100644 index 0000000000000..51940f59fdcb0 --- /dev/null +++ b/x-pack/plugins/search_homepage/public/components/indices_card/indices_card.scss @@ -0,0 +1,10 @@ +.override.euiListGroupItem { + .euiListGroupItem__label { + width: 100%; + padding: .5rem 0; + } +} + +.euiPanel.override { + min-height: 104px; +} diff --git a/x-pack/plugins/search_homepage/public/components/indices_card/indices_list.tsx b/x-pack/plugins/search_homepage/public/components/indices_card/indices_list.tsx new file mode 100644 index 0000000000000..790ecc50d2f2e --- /dev/null +++ b/x-pack/plugins/search_homepage/public/components/indices_card/indices_list.tsx @@ -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 React, { useCallback } from 'react'; +import { + EuiFlexGroup, + EuiListGroup, + EuiListGroupItem, + EuiSpacer, + EuiText, + useEuiTheme, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { GetIndicesIndexData } from '../../../common/types'; + +import { IndexListLabel } from './index_list_label'; + +export interface IndicesListProps { + indices: GetIndicesIndexData[]; +} +export const IndicesList = ({ indices }: IndicesListProps) => { + const { euiTheme } = useEuiTheme(); + const onClickIndex = useCallback((index: GetIndicesIndexData) => () => {}, []); + if (indices.length === 0) { + // Handle empty filter result + return ( + <> + + + + + + + + ); + } + return ( + + {indices.map((index) => ( + } + className="override" + style={{ + border: `1px solid ${euiTheme.colors.lightShade}`, + borderRadius: '.25rem', + }} + /> + ))} + + ); +}; diff --git a/x-pack/plugins/search_homepage/public/components/search_homepage.tsx b/x-pack/plugins/search_homepage/public/components/search_homepage.tsx index 76f34caad0a5d..af563b397b018 100644 --- a/x-pack/plugins/search_homepage/public/components/search_homepage.tsx +++ b/x-pack/plugins/search_homepage/public/components/search_homepage.tsx @@ -9,8 +9,7 @@ import React, { useMemo } from 'react'; import { EuiPageTemplate } from '@elastic/eui'; import { useKibana } from '../hooks/use_kibana'; -import { SearchHomepageBody } from './search_homepage_body'; -import { SearchHomepageHeader } from './search_homepage_header'; +import { HomepageView } from './homepage_view'; export const SearchHomepagePage = () => { const { @@ -24,8 +23,7 @@ export const SearchHomepagePage = () => { return ( - - + {embeddableConsole} ); diff --git a/x-pack/plugins/search_homepage/public/components/search_homepage_body.tsx b/x-pack/plugins/search_homepage/public/components/search_homepage_body.tsx index f27da23468711..64a2bf71b2b32 100644 --- a/x-pack/plugins/search_homepage/public/components/search_homepage_body.tsx +++ b/x-pack/plugins/search_homepage/public/components/search_homepage_body.tsx @@ -8,13 +8,32 @@ import React from 'react'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { IndicesCard } from './indices_card'; import { ConsoleLinkButton } from './console_link_button'; -export const SearchHomepageBody = () => ( +export interface SearchHomepageBodyProps { + onCreateIndex: () => void; +} + +export const SearchHomepageBody = ({ onCreateIndex }: SearchHomepageBodyProps) => ( - - - + + + + + + + + + + + + + + + + + diff --git a/x-pack/plugins/search_homepage/public/components/search_homepage_header.tsx b/x-pack/plugins/search_homepage/public/components/search_homepage_header.tsx index 200aa51af1d9f..9a69dea8e6683 100644 --- a/x-pack/plugins/search_homepage/public/components/search_homepage_header.tsx +++ b/x-pack/plugins/search_homepage/public/components/search_homepage_header.tsx @@ -11,6 +11,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { EndpointsHeaderAction } from './endpoints_header_action'; export interface SearchHomepageHeaderProps { + onCreateIndex: () => void; showEndpointsAPIKeys: boolean; } diff --git a/x-pack/plugins/search_homepage/public/components/stack_app.tsx b/x-pack/plugins/search_homepage/public/components/stack_app.tsx index e65fe5c73bdf3..ed468345c081b 100644 --- a/x-pack/plugins/search_homepage/public/components/stack_app.tsx +++ b/x-pack/plugins/search_homepage/public/components/stack_app.tsx @@ -6,20 +6,23 @@ */ import React from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; import { UsageTrackerContextProvider } from '../contexts/usage_tracker_context'; import { useKibana } from '../hooks/use_kibana'; -import { SearchHomepageBody } from './search_homepage_body'; -import { SearchHomepageHeader } from './search_homepage_header'; +import { initQueryClient } from '../utils/query_client'; +import { HomepageView } from './homepage_view'; export const App: React.FC = () => { const { - services: { usageCollection }, + services: { notifications, usageCollection }, } = useKibana(); + const queryClient = initQueryClient(notifications.toasts); return ( - - + + + ); }; diff --git a/x-pack/plugins/search_homepage/public/constants.ts b/x-pack/plugins/search_homepage/public/constants.ts new file mode 100644 index 0000000000000..f820f71fbb89a --- /dev/null +++ b/x-pack/plugins/search_homepage/public/constants.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +export enum QueryKeys { + FetchIndices = 'fetchIndices', +} + +export enum MutationKeys { + CreateIndex = 'createIndex', +} diff --git a/x-pack/plugins/search_homepage/public/hooks/api/use_create_index.ts b/x-pack/plugins/search_homepage/public/hooks/api/use_create_index.ts new file mode 100644 index 0000000000000..b9bea3dc0d599 --- /dev/null +++ b/x-pack/plugins/search_homepage/public/hooks/api/use_create_index.ts @@ -0,0 +1,24 @@ +/* + * 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 { useMutation } from '@tanstack/react-query'; + +import { MutationKeys } from '../../constants'; +import { useKibana } from '../use_kibana'; + +export const useCreateIndex = () => { + const { http } = useKibana().services; + + return useMutation({ + mutationKey: [MutationKeys.CreateIndex], + mutationFn: async (indexName: string) => { + return await http.put<{}>('/internal/index_management/indices/create', { + body: JSON.stringify({ indexName }), + }); + }, + }); +}; diff --git a/x-pack/plugins/search_homepage/public/hooks/api/use_indices.ts b/x-pack/plugins/search_homepage/public/hooks/api/use_indices.ts new file mode 100644 index 0000000000000..6928da805189d --- /dev/null +++ b/x-pack/plugins/search_homepage/public/hooks/api/use_indices.ts @@ -0,0 +1,26 @@ +/* + * 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 { useQuery } from '@tanstack/react-query'; + +import { APIRoutes } from '../../../common/routes'; +import { GetIndicesResponse } from '../../../common/types'; + +import { QueryKeys } from '../../constants'; +import { useKibana } from '../use_kibana'; + +export const useIndices = (query: string | undefined) => { + const { http } = useKibana().services; + + return useQuery({ + queryKey: [QueryKeys.FetchIndices, { filter: query && query.length > 0 ? query : 'all' }], + queryFn: () => + http.get(APIRoutes.GET_INDICES, { + query: { search_query: query }, + }), + }); +}; diff --git a/x-pack/plugins/search_homepage/public/hooks/use_asset_basepath.ts b/x-pack/plugins/search_homepage/public/hooks/use_asset_basepath.ts new file mode 100644 index 0000000000000..e3c60417cd521 --- /dev/null +++ b/x-pack/plugins/search_homepage/public/hooks/use_asset_basepath.ts @@ -0,0 +1,14 @@ +/* + * 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 { PLUGIN_ID } from '../../common'; +import { useKibana } from './use_kibana'; + +export const useAssetBasePath = () => { + const { http } = useKibana().services; + return http.basePath.prepend(`/plugins/${PLUGIN_ID}/assets`); +}; diff --git a/x-pack/plugins/search_homepage/public/types.ts b/x-pack/plugins/search_homepage/public/types.ts index b8faec2156831..76f9b031304f8 100644 --- a/x-pack/plugins/search_homepage/public/types.ts +++ b/x-pack/plugins/search_homepage/public/types.ts @@ -7,7 +7,7 @@ import type { ComponentProps, FC } from 'react'; import type { ConsolePluginStart } from '@kbn/console-plugin/public'; -import type { AppMountParameters } from '@kbn/core/public'; +import type { AppMountParameters, CoreStart } from '@kbn/core/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; import type { App } from './components/stack_app'; @@ -63,9 +63,10 @@ export interface SearchHomepageAppPluginStartDependencies { usageCollection?: UsageCollectionStart; } -export interface SearchHomepageServicesContext extends SearchHomepageAppPluginStartDependencies { - history: AppMountParameters['history']; -} +export type SearchHomepageServicesContext = SearchHomepageAppPluginStartDependencies & + CoreStart & { + history: AppMountParameters['history']; + }; export interface AppUsageTracker { click: (eventName: string | string[]) => void; diff --git a/x-pack/plugins/search_homepage/public/utils/get_error_message.ts b/x-pack/plugins/search_homepage/public/utils/get_error_message.ts new file mode 100644 index 0000000000000..4625b2cf5240c --- /dev/null +++ b/x-pack/plugins/search_homepage/public/utils/get_error_message.ts @@ -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 { KibanaServerError } from '@kbn/kibana-utils-plugin/common'; + +export function getErrorMessage(error: unknown, defaultMessage?: string): string { + if (typeof error === 'string') { + return error; + } + if (isKibanaServerError(error)) { + return error.body.message; + } + + if (typeof error === 'object' && (error as { name: string }).name) { + return (error as { name: string }).name; + } + + return defaultMessage ?? ''; +} + +export function getErrorCode(error: unknown): number | undefined { + if (isKibanaServerError(error)) { + return error.body.statusCode; + } + return undefined; +} + +export function isKibanaServerError( + input: unknown +): input is Error & { body: KibanaServerError; name: string; skipToast?: boolean } { + if ( + typeof input === 'object' && + (input as { body: KibanaServerError }).body && + typeof (input as { body: KibanaServerError }).body.message === 'string' + ) { + return true; + } + return false; +} diff --git a/x-pack/plugins/search_homepage/public/utils/is_valid_index_name.ts b/x-pack/plugins/search_homepage/public/utils/is_valid_index_name.ts new file mode 100644 index 0000000000000..cc2661c24faa0 --- /dev/null +++ b/x-pack/plugins/search_homepage/public/utils/is_valid_index_name.ts @@ -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. + */ + +// see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html for the current rules + +export function isValidIndexName(name: string) { + const byteLength = encodeURI(name).split(/%(?:u[0-9A-F]{2})?[0-9A-F]{2}|./).length - 1; + const reg = new RegExp('[\\\\/:*?"<>|\\s,#]+'); + const indexPatternInvalid = + byteLength > 255 || // name can't be greater than 255 bytes + name !== name.toLowerCase() || // name should be lowercase + name.match(/^[-_+.]/) !== null || // name can't start with these chars + name.match(reg) !== null || // name can't contain these chars + name.length === 0; // name can't be empty + + return !indexPatternInvalid; +} diff --git a/x-pack/plugins/search_homepage/public/utils/query_client.ts b/x-pack/plugins/search_homepage/public/utils/query_client.ts new file mode 100644 index 0000000000000..0d769c9323db1 --- /dev/null +++ b/x-pack/plugins/search_homepage/public/utils/query_client.ts @@ -0,0 +1,54 @@ +/* + * 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 { IToasts } from '@kbn/core/public'; +import { QueryClient, MutationCache, QueryCache, MutationKey } from '@tanstack/react-query'; +import { MutationKeys } from '../constants'; +import { getErrorCode, getErrorMessage, isKibanaServerError } from './get_error_message'; + +const SkipToastMutations = [MutationKeys.CreateIndex]; + +function shouldSkipMutationErrorToast(mutationKey: MutationKey | undefined): boolean { + if (mutationKey === undefined) return false; + if (Array.isArray(mutationKey) && mutationKey.length > 0) { + return SkipToastMutations.includes(mutationKey[0]); + } + if (typeof mutationKey === 'string') { + return SkipToastMutations.includes(mutationKey); + } + return false; +} + +export function initQueryClient(toasts: IToasts): QueryClient { + return new QueryClient({ + mutationCache: new MutationCache({ + onError: (error, _vars, _ctx, mutation) => { + if (shouldSkipMutationErrorToast(mutation.options.mutationKey)) return; + toasts.addError(error as Error, { + title: (error as Error).name, + toastMessage: getErrorMessage(error), + toastLifeTimeMs: 1000, + }); + }, + }), + queryCache: new QueryCache({ + onError: (error) => { + // 404s are often functionally okay and shouldn't show toasts by default + if (getErrorCode(error) === 404) { + return; + } + if (isKibanaServerError(error) && !error.skipToast) { + toasts.addError(error, { + title: error.name, + toastMessage: getErrorMessage(error), + toastLifeTimeMs: 1000, + }); + } + }, + }), + }); +} From 261951298e7d26eca474baa3b579eaa5ca42695e Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Mon, 22 Jul 2024 13:27:15 +0000 Subject: [PATCH 06/11] search_homepage: indices card add data shouldnt fill --- .../search_homepage/public/components/indices_card/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/plugins/search_homepage/public/components/indices_card/index.tsx b/x-pack/plugins/search_homepage/public/components/indices_card/index.tsx index 85f01f4b6ea16..25f66604f2059 100644 --- a/x-pack/plugins/search_homepage/public/components/indices_card/index.tsx +++ b/x-pack/plugins/search_homepage/public/components/indices_card/index.tsx @@ -124,7 +124,6 @@ export const IndicesCard = ({ onCreateIndex }: IndicesCardProps) => { Date: Mon, 22 Jul 2024 16:19:14 +0000 Subject: [PATCH 07/11] search_homepage: index link locators --- .../common/locators/index_details_locator.tsx | 26 +++++++++++++++++++ .../enterprise_search/public/plugin.ts | 5 ++++ .../components/indices_card/indices_list.tsx | 13 +++++++++- .../common/locators/index_details_locator.ts | 24 +++++++++++++++++ .../serverless_search/public/plugin.ts | 9 ++++++- .../plugins/serverless_search/public/types.ts | 3 ++- 6 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/common/locators/index_details_locator.tsx create mode 100644 x-pack/plugins/serverless_search/common/locators/index_details_locator.ts diff --git a/x-pack/plugins/enterprise_search/common/locators/index_details_locator.tsx b/x-pack/plugins/enterprise_search/common/locators/index_details_locator.tsx new file mode 100644 index 0000000000000..5be365e4d9a75 --- /dev/null +++ b/x-pack/plugins/enterprise_search/common/locators/index_details_locator.tsx @@ -0,0 +1,26 @@ +/* + * 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 { LocatorDefinition } from '@kbn/share-plugin/common'; +import { SerializableRecord } from '@kbn/utility-types'; + +import { ENTERPRISE_SEARCH_CONTENT_PLUGIN } from '../constants'; + +export interface IndexDetailsLocatorParams extends SerializableRecord { + indexId: string; +} + +export class IndexDetailsLocatorDefinition implements LocatorDefinition { + public readonly getLocation = async (params: IndexDetailsLocatorParams) => { + return { + app: ENTERPRISE_SEARCH_CONTENT_PLUGIN.ID, + path: `/search_indices/${params.indexId}`, + state: {}, + }; + }; + public readonly id = 'INDEX_DETAILS_LOCATOR_ID'; +} diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 27ad128a74438..1218cba6e2393 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -62,6 +62,10 @@ import { CreatIndexLocatorDefinition, CreatIndexLocatorParams, } from '../common/locators/create_index_locator'; +import { + IndexDetailsLocatorDefinition, + IndexDetailsLocatorParams, +} from '../common/locators/index_details_locator'; import { ClientConfigType, InitialAppData } from '../common/types'; import { ENGINES_PATH } from './applications/app_search/routes'; @@ -527,6 +531,7 @@ export class EnterpriseSearchPlugin implements Plugin { }); share?.url.locators.create(new CreatIndexLocatorDefinition()); + share?.url.locators.create(new IndexDetailsLocatorDefinition()); if (config.canDeployEntSearch) { core.application.register({ diff --git a/x-pack/plugins/search_homepage/public/components/indices_card/indices_list.tsx b/x-pack/plugins/search_homepage/public/components/indices_card/indices_list.tsx index 790ecc50d2f2e..7b231cfff605d 100644 --- a/x-pack/plugins/search_homepage/public/components/indices_card/indices_list.tsx +++ b/x-pack/plugins/search_homepage/public/components/indices_card/indices_list.tsx @@ -18,6 +18,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { GetIndicesIndexData } from '../../../common/types'; +import { useKibana } from '../../hooks/use_kibana'; import { IndexListLabel } from './index_list_label'; export interface IndicesListProps { @@ -25,7 +26,17 @@ export interface IndicesListProps { } export const IndicesList = ({ indices }: IndicesListProps) => { const { euiTheme } = useEuiTheme(); - const onClickIndex = useCallback((index: GetIndicesIndexData) => () => {}, []); + const { application, share } = useKibana().services; + const onClickIndex = useCallback( + (index: GetIndicesIndexData) => async () => { + const indexDetailsLocator = share?.url.locators.get('INDEX_DETAILS_LOCATOR_ID'); + if (indexDetailsLocator) { + const indexDetailsUrl = await indexDetailsLocator.getUrl({ indexId: index.name }); + application.navigateToUrl(indexDetailsUrl); + } + }, + [application, share] + ); if (indices.length === 0) { // Handle empty filter result return ( diff --git a/x-pack/plugins/serverless_search/common/locators/index_details_locator.ts b/x-pack/plugins/serverless_search/common/locators/index_details_locator.ts new file mode 100644 index 0000000000000..cf5dc432bcb6d --- /dev/null +++ b/x-pack/plugins/serverless_search/common/locators/index_details_locator.ts @@ -0,0 +1,24 @@ +/* + * 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 { LocatorDefinition } from '@kbn/share-plugin/common'; +import { SerializableRecord } from '@kbn/utility-types'; + +export interface IndexDetailsLocatorParams extends SerializableRecord { + indexId: string; +} + +export class IndexDetailsLocatorDefinition implements LocatorDefinition { + public readonly getLocation = async (params: IndexDetailsLocatorParams) => { + return { + app: 'management', + path: `/data/index_management/indices/index_details?indexName=${params.indexId}`, + state: {}, + }; + }; + public readonly id = 'INDEX_DETAILS_LOCATOR_ID'; +} diff --git a/x-pack/plugins/serverless_search/public/plugin.ts b/x-pack/plugins/serverless_search/public/plugin.ts index 7953474a099bf..240df44c00ddd 100644 --- a/x-pack/plugins/serverless_search/public/plugin.ts +++ b/x-pack/plugins/serverless_search/public/plugin.ts @@ -20,6 +20,10 @@ import { of } from 'rxjs'; import { createIndexMappingsDocsLinkContent as createIndexMappingsContent } from './application/components/index_management/index_mappings_docs_link'; import { createIndexOverviewContent } from './application/components/index_management/index_overview_content'; import { docLinks } from '../common/doc_links'; +import { + IndexDetailsLocatorDefinition, + IndexDetailsLocatorParams, +} from '../common/locators/index_details_locator'; import { ServerlessSearchPluginSetup, ServerlessSearchPluginSetupDependencies, @@ -43,7 +47,7 @@ export class ServerlessSearchPlugin core: CoreSetup, setupDeps: ServerlessSearchPluginSetupDependencies ): ServerlessSearchPluginSetup { - const { searchHomepage } = setupDeps; + const { searchHomepage, share } = setupDeps; const useSearchHomepage = searchHomepage && searchHomepage.isHomepageFeatureEnabled(); const queryClient = new QueryClient({ @@ -134,6 +138,9 @@ export class ServerlessSearchPlugin setupDeps.discover.showInlineTopNav(); + // Locators + share?.url.locators.create(new IndexDetailsLocatorDefinition()); + return {}; } diff --git a/x-pack/plugins/serverless_search/public/types.ts b/x-pack/plugins/serverless_search/public/types.ts index d3011210c524f..52e096b8dfa29 100644 --- a/x-pack/plugins/serverless_search/public/types.ts +++ b/x-pack/plugins/serverless_search/public/types.ts @@ -12,7 +12,7 @@ import type { SearchPlaygroundPluginStart } from '@kbn/search-playground/public' import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public'; import type { SecurityPluginStart } from '@kbn/security-plugin/public'; import type { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverless/public'; -import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public'; import type { IndexManagementPluginStart } from '@kbn/index-management-plugin/public'; import type { DiscoverSetup } from '@kbn/discover-plugin/public'; import type { @@ -32,6 +32,7 @@ export interface ServerlessSearchPluginSetupDependencies { serverless: ServerlessPluginSetup; discover: DiscoverSetup; searchHomepage?: SearchHomepagePluginSetup; + share: SharePluginSetup; } export interface ServerlessSearchPluginStartDependencies { From 8903e6d5a78d1dd1fccd3ed65f8a6c3d3bf685db Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 22 Jul 2024 16:32:53 +0000 Subject: [PATCH 08/11] [CI] Auto-commit changed files from 'node scripts/lint_ts_projects --fix' --- x-pack/plugins/search_homepage/tsconfig.json | 3 +++ x-pack/plugins/serverless_search/tsconfig.json | 1 + 2 files changed, 4 insertions(+) diff --git a/x-pack/plugins/search_homepage/tsconfig.json b/x-pack/plugins/search_homepage/tsconfig.json index fedcd90ab2cf3..1b1c0b36825fa 100644 --- a/x-pack/plugins/search_homepage/tsconfig.json +++ b/x-pack/plugins/search_homepage/tsconfig.json @@ -24,6 +24,9 @@ "@kbn/config-schema", "@kbn/cloud", "@kbn/analytics", + "@kbn/enterprise-search-plugin", + "@kbn/kibana-utils-plugin", + "@kbn/logging", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/serverless_search/tsconfig.json b/x-pack/plugins/serverless_search/tsconfig.json index c19ec3af2f4fc..f67ed95309dba 100644 --- a/x-pack/plugins/serverless_search/tsconfig.json +++ b/x-pack/plugins/serverless_search/tsconfig.json @@ -52,5 +52,6 @@ "@kbn/search-inference-endpoints", "@kbn/search-homepage", "@kbn/security-plugin-types-common", + "@kbn/utility-types", ] } From b4b5ac96e0e2b561e91ac5553f8c3db91058a88f Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Mon, 22 Jul 2024 19:34:00 +0000 Subject: [PATCH 09/11] search_homepage: removed ref to ent-search context Removed the import of the ent-search context. Instead we will need to share search context in another way that doesn't result in circular references :/ --- x-pack/plugins/search_homepage/public/hooks/use_kibana.ts | 4 +--- x-pack/plugins/search_homepage/tsconfig.json | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/x-pack/plugins/search_homepage/public/hooks/use_kibana.ts b/x-pack/plugins/search_homepage/public/hooks/use_kibana.ts index 621a843fca6dd..b22c7b4ed9d7f 100644 --- a/x-pack/plugins/search_homepage/public/hooks/use_kibana.ts +++ b/x-pack/plugins/search_homepage/public/hooks/use_kibana.ts @@ -6,8 +6,6 @@ */ import { useKibana as _useKibana } from '@kbn/kibana-react-plugin/public'; -import type { EnterpriseSearchKibanaServicesContext } from '@kbn/enterprise-search-plugin/public'; import { SearchHomepageServicesContext } from '../types'; -export const useKibana = () => - _useKibana(); +export const useKibana = () => _useKibana(); diff --git a/x-pack/plugins/search_homepage/tsconfig.json b/x-pack/plugins/search_homepage/tsconfig.json index 1b1c0b36825fa..66da7f1ecd5cf 100644 --- a/x-pack/plugins/search_homepage/tsconfig.json +++ b/x-pack/plugins/search_homepage/tsconfig.json @@ -24,7 +24,6 @@ "@kbn/config-schema", "@kbn/cloud", "@kbn/analytics", - "@kbn/enterprise-search-plugin", "@kbn/kibana-utils-plugin", "@kbn/logging", ], From 5f9ba9166f9641c3a55239fba1a03fe1ed836ba7 Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Tue, 23 Jul 2024 14:51:17 +0000 Subject: [PATCH 10/11] fix(search_homepage): service context type error --- x-pack/plugins/search_homepage/public/application.tsx | 2 +- x-pack/plugins/search_homepage/public/plugin.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/search_homepage/public/application.tsx b/x-pack/plugins/search_homepage/public/application.tsx index 462d62557d7d5..40aced24b605a 100644 --- a/x-pack/plugins/search_homepage/public/application.tsx +++ b/x-pack/plugins/search_homepage/public/application.tsx @@ -26,7 +26,7 @@ export const renderApp = async ( const queryClient = initQueryClient(core.notifications.toasts); ReactDOM.render( - + diff --git a/x-pack/plugins/search_homepage/public/plugin.ts b/x-pack/plugins/search_homepage/public/plugin.ts index 62cbef2780bae..bc064d2dceb62 100644 --- a/x-pack/plugins/search_homepage/public/plugin.ts +++ b/x-pack/plugins/search_homepage/public/plugin.ts @@ -57,12 +57,13 @@ export class SearchHomepagePlugin async mount({ element, history }: AppMountParameters) { const { renderApp } = await import('./application'); const [coreStart, depsStart] = await core.getStartServices(); - const startDeps: SearchHomepageServicesContext = { + const services: SearchHomepageServicesContext = { + ...coreStart, ...depsStart, history, }; - return renderApp(coreStart, startDeps, element); + return renderApp(coreStart, services, element); }, }); From ca5d165e37a62a875d84ab1a7d1592f60b0fad9e Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Tue, 23 Jul 2024 14:53:05 +0000 Subject: [PATCH 11/11] fix(search_homepage): route options type error --- x-pack/plugins/search_homepage/server/plugin.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/search_homepage/server/plugin.ts b/x-pack/plugins/search_homepage/server/plugin.ts index c2c77f86ae6ee..3caff609c8166 100644 --- a/x-pack/plugins/search_homepage/server/plugin.ts +++ b/x-pack/plugins/search_homepage/server/plugin.ts @@ -26,10 +26,12 @@ export class SearchHomepagePlugin const router = core.http.createRouter(); defineRoutes({ - getStartServices: core.getStartServices, logger: this.logger, router, - options: { hasIndexStats: this.config.enableIndexStats }, + options: { + hasIndexStats: this.config.enableIndexStats, + getStartServices: core.getStartServices, + }, }); return {};