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/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/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..1218cba6e2393 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, @@ -61,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'; @@ -110,8 +115,11 @@ export interface PluginsStart { searchInferenceEndpoints?: SearchInferenceEndpointsPluginStart; security?: SecurityPluginStart; share?: SharePluginStart; + usageCollection?: UsageCollectionStart; } +export type EnterpriseSearchKibanaServicesContext = CoreStart & PluginsStart; + export interface ESConfig { elasticsearch_host: string; } @@ -523,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/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/public/application.tsx b/x-pack/plugins/search_homepage/public/application.tsx index 5d2a04a97cc63..40aced24b605a 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 0000000000000..6f268f1b0dfa0 Binary files /dev/null and b/x-pack/plugins/search_homepage/public/assets/no_data.png differ diff --git a/x-pack/plugins/search_homepage/public/components/create_index_modal.tsx b/x-pack/plugins/search_homepage/public/components/create_index_modal.tsx new file mode 100644 index 0000000000000..553410ebbed02 --- /dev/null +++ b/x-pack/plugins/search_homepage/public/components/create_index_modal.tsx @@ -0,0 +1,175 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { useQueryClient } from '@tanstack/react-query'; + +import { useKibana } from '../hooks/use_kibana'; +import { useCreateIndex } from '../hooks/api/use_create_index'; +import { getErrorMessage } from '../utils/get_error_message'; +import { isValidIndexName } from '../utils/is_valid_index_name'; +import { QueryKeys } from '../constants'; + +const INVALID_INDEX_NAME_ERROR = i18n.translate( + 'xpack.searchHomepage.createIndex.modal.invalidName.error', + { defaultMessage: 'Index name is not valid' } +); + +export interface CreateIndexModalProps { + closeModal: () => 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..25f66604f2059 --- /dev/null +++ b/x-pack/plugins/search_homepage/public/components/indices_card/index.tsx @@ -0,0 +1,172 @@ +/* + * 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..7b231cfff605d --- /dev/null +++ b/x-pack/plugins/search_homepage/public/components/indices_card/indices_list.tsx @@ -0,0 +1,79 @@ +/* + * 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 { useKibana } from '../../hooks/use_kibana'; +import { IndexListLabel } from './index_list_label'; + +export interface IndicesListProps { + indices: GetIndicesIndexData[]; +} +export const IndicesList = ({ indices }: IndicesListProps) => { + const { euiTheme } = useEuiTheme(); + 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 ( + <> + + + + + + + + ); + } + 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/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); }, }); 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, + }); + } + }, + }), + }); +} 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/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/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 f446ba4e41fd3..3caff609c8166 100644 --- a/x-pack/plugins/search_homepage/server/plugin.ts +++ b/x-pack/plugins/search_homepage/server/plugin.ts @@ -5,24 +5,41 @@ * 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 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>) { this.logger.debug('searchHomepage: Setup'); + const router = core.http.createRouter(); + + defineRoutes({ + logger: this.logger, + router, + options: { + hasIndexStats: this.config.enableIndexStats, + getStartServices: core.getStartServices, + }, + }); + 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; + } + }; +} diff --git a/x-pack/plugins/search_homepage/tsconfig.json b/x-pack/plugins/search_homepage/tsconfig.json index fedcd90ab2cf3..66da7f1ecd5cf 100644 --- a/x-pack/plugins/search_homepage/tsconfig.json +++ b/x-pack/plugins/search_homepage/tsconfig.json @@ -24,6 +24,8 @@ "@kbn/config-schema", "@kbn/cloud", "@kbn/analytics", + "@kbn/kibana-utils-plugin", + "@kbn/logging", ], "exclude": [ "target/**/*", 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 { 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", ] } 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')); }); }