From 5798255638eebf33a68d15314f149f24528a241e Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Wed, 20 Dec 2023 16:10:33 -0600 Subject: [PATCH] [Serverless Search] Index Management - Index Details Overview (#173581) ## Summary This PR implements a new Overview tab for an Index details in Index Management for Serverless. It has data panels for information about the index and optional empty states if the index has no documents. (see Screenshots) ### Screenshots Index with data ![image](https://github.com/elastic/kibana/assets/1972968/848d7f50-8332-4119-b23b-7e4569a2eaba) Index without data ![image](https://github.com/elastic/kibana/assets/1972968/a126f94f-1112-4738-abd8-5d40d96d35dc) ![image](https://github.com/elastic/kibana/assets/1972968/e5f11f2f-f5a4-421e-b212-ce65307756d4) ![image](https://github.com/elastic/kibana/assets/1972968/bd7d7970-8e4c-4245-baec-8ab9d3625dbd) Connector Index without data ![image](https://github.com/elastic/kibana/assets/1972968/dc0d97ed-110b-4566-bddd-f2c145d2eadf) ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --- .../serverless_search/common/doc_links.ts | 2 + .../serverless_search/common/types/index.ts | 11 + .../application/components/badge_list.tsx | 31 ++ .../connectors/connectors_table.tsx | 2 +- .../connectors/empty_connectors_prompt.tsx | 12 +- .../components/connectors_router.tsx | 3 - .../components/index_documents/documents.tsx | 4 + .../index_documents/documents_tab.tsx | 13 +- .../index_management/api_empty_prompt.tsx | 166 +++++++++++ .../connector_empty_prompt.tsx | 33 +++ .../connector_setup_prompt.tsx | 100 +++++++ .../index_management/index_aliases_flyout.tsx | 75 +++++ .../index_mappings_docs_link.tsx | 0 .../index_management/index_overview.tsx | 271 ++++++++++++++++++ .../index_overview_content.tsx | 40 +++ .../overview_empty_prompt.tsx | 173 +++++++++++ .../index_management/overview_panel.tsx | 41 +++ .../components/languages/dotnet.ts | 16 ++ .../application/components/languages/java.ts | 50 ++++ .../public/application/constants.ts | 4 + .../hooks/api/use_create_connector.tsx | 10 +- .../application/hooks/api/use_index.tsx | 21 ++ .../serverless_search/public/plugin.ts | 9 +- .../server/lib/indices/fetch_index.test.ts | 142 +++++++++ .../server/lib/indices/fetch_index.ts | 45 +++ .../server/routes/indices_routes.ts | 22 ++ 26 files changed, 1278 insertions(+), 18 deletions(-) create mode 100644 x-pack/plugins/serverless_search/public/application/components/badge_list.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/index_management/api_empty_prompt.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/index_management/connector_empty_prompt.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/index_management/connector_setup_prompt.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/index_management/index_aliases_flyout.tsx rename x-pack/plugins/serverless_search/public/application/components/{ => index_management}/index_mappings_docs_link.tsx (100%) create mode 100644 x-pack/plugins/serverless_search/public/application/components/index_management/index_overview.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/index_management/index_overview_content.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/index_management/overview_empty_prompt.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/index_management/overview_panel.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/hooks/api/use_index.tsx create mode 100644 x-pack/plugins/serverless_search/server/lib/indices/fetch_index.test.ts create mode 100644 x-pack/plugins/serverless_search/server/lib/indices/fetch_index.ts diff --git a/x-pack/plugins/serverless_search/common/doc_links.ts b/x-pack/plugins/serverless_search/common/doc_links.ts index c3cfecd7dfc66f..7168f089c41e17 100644 --- a/x-pack/plugins/serverless_search/common/doc_links.ts +++ b/x-pack/plugins/serverless_search/common/doc_links.ts @@ -19,6 +19,7 @@ class ESDocLinks { public roleDescriptors: string = ''; public securityApis: string = ''; public ingestionPipelines: string = ''; + public dataStreams: string = ''; // Client links public elasticsearchClients: string = ''; // go @@ -61,6 +62,7 @@ class ESDocLinks { this.roleDescriptors = newDocLinks.serverlessSecurity.apiKeyPrivileges; this.securityApis = newDocLinks.apis.securityApis; this.ingestionPipelines = newDocLinks.ingest.pipelines; + this.dataStreams = newDocLinks.elasticsearch.dataStreams; // Client links this.elasticsearchClients = newDocLinks.serverlessClients.clientLib; diff --git a/x-pack/plugins/serverless_search/common/types/index.ts b/x-pack/plugins/serverless_search/common/types/index.ts index c4dac1508374e0..6b43747b19afd9 100644 --- a/x-pack/plugins/serverless_search/common/types/index.ts +++ b/x-pack/plugins/serverless_search/common/types/index.ts @@ -5,6 +5,9 @@ * 2.0. */ +import { IndicesIndexState, IndicesStatsIndicesStats } from '@elastic/elasticsearch/lib/api/types'; +import { Connector } from '@kbn/search-connectors/types/connectors'; + export interface CreateAPIKeyArgs { expiration?: string; metadata?: Record; @@ -20,3 +23,11 @@ export interface IndexData { export interface FetchIndicesResult { indices: IndexData[]; } + +export interface FetchIndexResult { + index: IndicesIndexState & { + connector?: Connector; + count: number; + stats?: IndicesStatsIndicesStats; + }; +} diff --git a/x-pack/plugins/serverless_search/public/application/components/badge_list.tsx b/x-pack/plugins/serverless_search/public/application/components/badge_list.tsx new file mode 100644 index 00000000000000..9635515bdf419c --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/badge_list.tsx @@ -0,0 +1,31 @@ +/* + * 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 { EuiBadgeGroup, EuiBadge } from '@elastic/eui'; + +export interface BadgeListProps { + badges: React.ReactNode[]; + maxBadgesToDisplay?: number; +} + +export const BadgeList = ({ badges, maxBadgesToDisplay }: BadgeListProps) => { + const maxBadges = maxBadgesToDisplay ?? 3; + if (badges.length === 0) { + return <>; + } + + const badgesToDisplay = badges.slice(0, maxBadges); + return ( + + {badgesToDisplay.map((badge) => badge)} + {badges.length > maxBadges && ( + +{badges.length - maxBadges} + )} + + ); +}; diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors/connectors_table.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors/connectors_table.tsx index 53d99b37c99a1f..4850bdf8b86e3a 100644 --- a/x-pack/plugins/serverless_search/public/application/components/connectors/connectors_table.tsx +++ b/x-pack/plugins/serverless_search/public/application/components/connectors/connectors_table.tsx @@ -45,7 +45,7 @@ import { import { useConnectors } from '../../hooks/api/use_connectors'; import { useConnectorTypes } from '../../hooks/api/use_connector_types'; import { useKibanaServices } from '../../hooks/use_kibana'; -import { EDIT_CONNECTOR_PATH } from '../connectors_router'; +import { EDIT_CONNECTOR_PATH } from '../../constants'; import { DeleteConnectorModal } from './delete_connector_modal'; export const ConnectorsTable: React.FC = () => { diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors/empty_connectors_prompt.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors/empty_connectors_prompt.tsx index abb0ad64242d74..218c956c78770f 100644 --- a/x-pack/plugins/serverless_search/public/application/components/connectors/empty_connectors_prompt.tsx +++ b/x-pack/plugins/serverless_search/public/application/components/connectors/empty_connectors_prompt.tsx @@ -19,14 +19,16 @@ import { import { i18n } from '@kbn/i18n'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { PLUGIN_ID } from '../../../../common'; + import { useConnectorTypes } from '../../hooks/api/use_connector_types'; -import { useKibanaServices } from '../../hooks/use_kibana'; +import { useCreateConnector } from '../../hooks/api/use_create_connector'; +import { useAssetBasePath } from '../../hooks/use_asset_base_path'; export const EmptyConnectorsPrompt: React.FC = () => { - const { http } = useKibanaServices(); const { data: connectorTypes } = useConnectorTypes(); - const assetBasePath = http.basePath.prepend(`/plugins/${PLUGIN_ID}/assets`); + const { createConnector, isLoading } = useCreateConnector(); + + const assetBasePath = useAssetBasePath(); const connectorsPath = assetBasePath + '/connectors.svg'; return ( @@ -167,6 +169,8 @@ export const EmptyConnectorsPrompt: React.FC = () => { data-test-subj="serverlessSearchEmptyConnectorsPromptCreateConnectorButton" fill iconType="plusInCircleFilled" + onClick={() => createConnector()} + isLoading={isLoading} > {i18n.translate('xpack.serverlessSearch.connectorsEmpty.createConnector', { defaultMessage: 'Create connector', diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors_router.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors_router.tsx index ab1ce4e2902bf0..f8c224ed2c9c61 100644 --- a/x-pack/plugins/serverless_search/public/application/components/connectors_router.tsx +++ b/x-pack/plugins/serverless_search/public/application/components/connectors_router.tsx @@ -10,9 +10,6 @@ import React from 'react'; import { EditConnector } from './connectors/edit_connector'; import { ConnectorsOverview } from './connectors_overview'; -export const BASE_CONNECTORS_PATH = 'connectors'; -export const EDIT_CONNECTOR_PATH = `${BASE_CONNECTORS_PATH}/:id`; - export const ConnectorsRouter: React.FC = () => { return ( diff --git a/x-pack/plugins/serverless_search/public/application/components/index_documents/documents.tsx b/x-pack/plugins/serverless_search/public/application/components/index_documents/documents.tsx index 8d719485973e40..d74c3a479a68a3 100644 --- a/x-pack/plugins/serverless_search/public/application/components/index_documents/documents.tsx +++ b/x-pack/plugins/serverless_search/public/application/components/index_documents/documents.tsx @@ -74,3 +74,7 @@ export const IndexDocuments: React.FC = ({ indexName }) => /> ); }; + +// Default Export is needed to lazy load this react component +// eslint-disable-next-line import/no-default-export +export default IndexDocuments; diff --git a/x-pack/plugins/serverless_search/public/application/components/index_documents/documents_tab.tsx b/x-pack/plugins/serverless_search/public/application/components/index_documents/documents_tab.tsx index c71089b5f3b83c..ff4f2165f534c4 100644 --- a/x-pack/plugins/serverless_search/public/application/components/index_documents/documents_tab.tsx +++ b/x-pack/plugins/serverless_search/public/application/components/index_documents/documents_tab.tsx @@ -6,16 +6,19 @@ */ import { IndexDetailsTab } from '@kbn/index-management-plugin/common/constants'; -import React from 'react'; +import React, { Suspense, lazy } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { CoreStart } from '@kbn/core-lifecycle-browser'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; + import { ServerlessSearchPluginStartDependencies } from '../../../types'; -import { IndexDocuments } from './documents'; -export const createIndexOverviewContent = ( +const IndexDocuments = lazy(() => import('./documents')); + +export const createIndexDocumentsContent = ( core: CoreStart, services: ServerlessSearchPluginStartDependencies ): IndexDetailsTab => { @@ -34,7 +37,9 @@ export const createIndexOverviewContent = ( - + }> + + ); diff --git a/x-pack/plugins/serverless_search/public/application/components/index_management/api_empty_prompt.tsx b/x-pack/plugins/serverless_search/public/application/components/index_management/api_empty_prompt.tsx new file mode 100644 index 00000000000000..4881c8d73f40f1 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/index_management/api_empty_prompt.tsx @@ -0,0 +1,166 @@ +/* + * 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, { useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiEmptyPrompt, + EuiFlexItem, + EuiFlexGroup, + EuiIcon, + EuiText, + EuiButtonEmpty, + EuiLink, + EuiPanel, + EuiTitle, + EuiSpacer, + EuiSteps, +} from '@elastic/eui'; +import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; +import { + CodeBox, + getConsoleRequest, + getLanguageDefinitionCodeSnippet, + LanguageDefinition, + LanguageDefinitionSnippetArguments, +} from '@kbn/search-api-panels'; + +import { BACK_LABEL } from '../../../../common/i18n_string'; + +import { useAssetBasePath } from '../../hooks/use_asset_base_path'; +import { useKibanaServices } from '../../hooks/use_kibana'; +import { javaDefinition } from '../languages/java'; +import { languageDefinitions } from '../languages/languages'; +import { LanguageGrid } from '../languages/language_grid'; +import { + API_KEY_PLACEHOLDER, + CLOUD_ID_PLACEHOLDER, + ELASTICSEARCH_URL_PLACEHOLDER, +} from '../../constants'; + +import { ApiKeyPanel } from '../api_key/api_key'; + +export interface APIIndexEmptyPromptProps { + indexName: string; + onBackClick?: () => void; +} + +export const APIIndexEmptyPrompt = ({ indexName, onBackClick }: APIIndexEmptyPromptProps) => { + const { application, cloud, share } = useKibanaServices(); + const assetBasePath = useAssetBasePath(); + const [selectedLanguage, setSelectedLanguage] = + React.useState(javaDefinition); + const [clientApiKey, setClientApiKey] = useState(API_KEY_PLACEHOLDER); + const { elasticsearchURL, cloudId } = useMemo(() => { + return { + elasticsearchURL: cloud?.elasticsearchUrl ?? ELASTICSEARCH_URL_PLACEHOLDER, + cloudId: cloud?.cloudId ?? CLOUD_ID_PLACEHOLDER, + }; + }, [cloud]); + const codeSnippetArguments: LanguageDefinitionSnippetArguments = { + url: elasticsearchURL, + apiKey: clientApiKey, + cloudId, + indexName, + }; + + const apiIngestSteps: EuiContainedStepProps[] = [ + { + title: i18n.translate( + 'xpack.serverlessSearch.indexManagement.indexDetails.overview.emptyPrompt.api.ingest.title', + { defaultMessage: 'Ingest data via API using a programming language client' } + ), + children: ( + + + + + + + + + ), + }, + { + title: i18n.translate( + 'xpack.serverlessSearch.indexManagement.indexDetails.overview.emptyPrompt.api.apiKey.title', + { defaultMessage: 'Prepare an API key' } + ), + children: , + }, + ]; + + return ( + + + {BACK_LABEL} + + } + title={ + +
+ +
+
+ } + body={ + +

+ application.navigateToApp('elasticsearch')} + > + {i18n.translate( + 'xpack.serverlessSearch.indexManagement.indexDetails.overview.emptyPrompt.api.body.getStartedLink', + { defaultMessage: 'Get started' } + )} + + ), + }} + /> +

+
+ } + /> + + +
+ ); +}; diff --git a/x-pack/plugins/serverless_search/public/application/components/index_management/connector_empty_prompt.tsx b/x-pack/plugins/serverless_search/public/application/components/index_management/connector_empty_prompt.tsx new file mode 100644 index 00000000000000..487c80ce48b6f2 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/index_management/connector_empty_prompt.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButtonEmpty, EuiPanel } from '@elastic/eui'; + +import { BACK_LABEL } from '../../../../common/i18n_string'; +import { EmptyConnectorsPrompt } from '../connectors/empty_connectors_prompt'; + +interface ConnectorIndexEmptyPromptProps { + indexName: string; + onBackClick?: () => void; +} + +export const ConnectorIndexEmptyPrompt = ({ onBackClick }: ConnectorIndexEmptyPromptProps) => { + return ( + + + {BACK_LABEL} + + + + ); +}; diff --git a/x-pack/plugins/serverless_search/public/application/components/index_management/connector_setup_prompt.tsx b/x-pack/plugins/serverless_search/public/application/components/index_management/connector_setup_prompt.tsx new file mode 100644 index 00000000000000..888a5f11385a54 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/index_management/connector_setup_prompt.tsx @@ -0,0 +1,100 @@ +/* + * 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 { + EuiEmptyPrompt, + EuiIcon, + EuiFlexItem, + EuiFlexGroup, + EuiButton, + EuiPanel, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { Connector } from '@kbn/search-connectors'; + +import { useKibanaServices } from '../../hooks/use_kibana'; +import { useAssetBasePath } from '../../hooks/use_asset_base_path'; +import { useConnectorTypes } from '../../hooks/api/use_connector_types'; + +interface ConnectorSetupEmptyPromptProps { + indexName: string; + connector: Connector; +} + +export const ConnectorSetupEmptyPrompt = ({ connector }: ConnectorSetupEmptyPromptProps) => { + const { http } = useKibanaServices(); + const assetBasePath = useAssetBasePath(); + const { data: connectorTypes } = useConnectorTypes(); + + const connectorsIconPath = assetBasePath + '/connectors.svg'; + const connectorPath = http.basePath.prepend(`/app/connectors/${connector.id}`); + const connectorType = connectorTypes?.connectors?.find( + (cType) => cType.serviceType === connector.service_type + ); + return ( + + } + title={ + +
+ +
+
+ } + body={ + <> +

+ +

+ {!!connectorType && ( + <> + + + + + {connectorType.name} + + + )} + + } + actions={ + + + + } + /> +
+ ); +}; diff --git a/x-pack/plugins/serverless_search/public/application/components/index_management/index_aliases_flyout.tsx b/x-pack/plugins/serverless_search/public/application/components/index_management/index_aliases_flyout.tsx new file mode 100644 index 00000000000000..5f87fd2ab0b81b --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/index_management/index_aliases_flyout.tsx @@ -0,0 +1,75 @@ +/* + * 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 { + EuiBasicTable, + EuiBasicTableColumn, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; + +export interface IndexAliasesFlyoutProps { + aliases: string[]; + indexName: string; + onClose: () => void; +} + +export const IndexAliasesFlyout = ({ indexName, aliases, onClose }: IndexAliasesFlyoutProps) => { + const aliasItems = aliases.map((alias) => ({ name: alias })); + const columns: Array> = [ + { + field: 'name', + name: i18n.translate( + 'xpack.serverlessSearch.indexManagement.indexDetails.overview.aliasesFlyout.table.nameColumn.header', + { defaultMessage: 'Alias Name' } + ), + 'data-test-subj': 'aliasNameCell', + truncateText: false, + width: '100%', + }, + ]; + return ( + + + +

+ +

+
+
+ + + + + + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/serverless_search/public/application/components/index_mappings_docs_link.tsx b/x-pack/plugins/serverless_search/public/application/components/index_management/index_mappings_docs_link.tsx similarity index 100% rename from x-pack/plugins/serverless_search/public/application/components/index_mappings_docs_link.tsx rename to x-pack/plugins/serverless_search/public/application/components/index_management/index_mappings_docs_link.tsx diff --git a/x-pack/plugins/serverless_search/public/application/components/index_management/index_overview.tsx b/x-pack/plugins/serverless_search/public/application/components/index_management/index_overview.tsx new file mode 100644 index 00000000000000..f1db8d49e038a0 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/index_management/index_overview.tsx @@ -0,0 +1,271 @@ +/* + * 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, { FunctionComponent } from 'react'; +import numeral from '@elastic/numeral'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, FormattedPlural } from '@kbn/i18n-react'; +import { + EuiLoadingSpinner, + EuiEmptyPrompt, + EuiFlexGrid, + EuiFlexItem, + EuiFlexGroup, + EuiIcon, + EuiI18nNumber, + EuiText, + EuiBadge, + EuiButton, + EuiLink, + EuiSpacer, +} from '@elastic/eui'; + +import { Index } from '@kbn/index-management-plugin/common/types/indices'; + +import { docLinks } from '../../../../common/doc_links'; +import { useIndex } from '../../hooks/api/use_index'; + +import { BadgeList } from '../badge_list'; +import { OverviewEmptyPrompt } from './overview_empty_prompt'; +import { IndexOverviewPanel, IndexOverviewPanelStat } from './overview_panel'; +import { IndexAliasesFlyout } from './index_aliases_flyout'; + +export interface IndexDetailOverviewProps { + index: Index; +} + +export const IndexDetailOverview: FunctionComponent = ({ index }) => { + const [aliasesFlyoutOpen, setAliasesFlyoutOpen] = React.useState(false); + const { data, isLoading, isError } = useIndex(index.name); + const indexAliases = + typeof index.aliases === 'string' + ? index.aliases.length > 0 && index.aliases !== 'none' + ? [index.aliases] + : [] + : index.aliases; + + if (isLoading || !data) + return ( + } + title={ +

+ +

+ } + /> + ); + if (isError) { + return ( + + + + } + body={ +

+ +

+ } + /> + ); + } + + const indexData = data.index; + return ( + <> + {aliasesFlyoutOpen && ( + setAliasesFlyoutOpen(false)} + /> + )} + + + + } + footer={ + + + + } + > + {index.data_stream ?? '--'} + + + + + } + footer={ + 0 + ? indexAliases.map((alias) => {alias}) + : [ + + + , + ] + } + /> + } + > + + + + + + + + +

+ +

+
+
+ + setAliasesFlyoutOpen(true)} + > + + + +
+
+
+ {indexData.count > 0 && ( + + + } + footer={ + + + + + + + , + deletedCount: ( + + ), + }} + /> + + + + } + > + + + + {numeral( + indexData.stats?.primaries?.store?.total_data_set_size_in_bytes ?? 0 + ).format('0 b')} + + + + +

+ +

+
+
+ + + {numeral( + indexData.stats?.total?.store?.total_data_set_size_in_bytes ?? 0 + ).format('0 b')} + + + + +

+ +

+
+
+
+
+
+ )} +
+ {indexData.count === 0 && ( + <> + + + + )} + + ); +}; + +// Default Export is needed to lazy load this react component +// eslint-disable-next-line import/no-default-export +export default IndexDetailOverview; diff --git a/x-pack/plugins/serverless_search/public/application/components/index_management/index_overview_content.tsx b/x-pack/plugins/serverless_search/public/application/components/index_management/index_overview_content.tsx new file mode 100644 index 00000000000000..373dce25575718 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/index_management/index_overview_content.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, { Suspense, lazy } from 'react'; +import { CoreStart } from '@kbn/core/public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { EuiLoadingSpinner } from '@elastic/eui'; + +import { IndexContent } from '@kbn/index-management-plugin/public/services'; + +import { ServerlessSearchPluginStartDependencies } from '../../../types'; + +const IndexDetailOverview = lazy(() => import('./index_overview')); + +export const createIndexOverviewContent = ( + core: CoreStart, + services: ServerlessSearchPluginStartDependencies +): IndexContent => { + return { + renderContent: (index) => { + const queryClient = new QueryClient(); + return ( + + + + }> + + + + + ); + }, + }; +}; diff --git a/x-pack/plugins/serverless_search/public/application/components/index_management/overview_empty_prompt.tsx b/x-pack/plugins/serverless_search/public/application/components/index_management/overview_empty_prompt.tsx new file mode 100644 index 00000000000000..b790021483e07f --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/index_management/overview_empty_prompt.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 from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiEmptyPrompt, + EuiFlexItem, + EuiFlexGroup, + EuiText, + EuiButton, + EuiLink, + EuiPanel, + EuiTitle, +} from '@elastic/eui'; +import { Connector } from '@kbn/search-connectors'; + +import { docLinks } from '../../../../common/doc_links'; + +import { APIIndexEmptyPrompt } from './api_empty_prompt'; +import { ConnectorIndexEmptyPrompt } from './connector_empty_prompt'; +import { ConnectorSetupEmptyPrompt } from './connector_setup_prompt'; + +enum EmptyPromptView { + Default, + NewConnector, + API, + SetupConnector, +} + +export interface OverviewEmptyPromptProps { + indexName: string; + connector?: Connector; +} + +export const OverviewEmptyPrompt = ({ connector, indexName }: OverviewEmptyPromptProps) => { + const isConnectorIndex = !!connector; + const [currentView, setView] = React.useState( + isConnectorIndex ? EmptyPromptView.SetupConnector : EmptyPromptView.Default + ); + if (currentView === EmptyPromptView.SetupConnector) { + return ; + } + if (currentView === EmptyPromptView.NewConnector) { + return ( + setView(EmptyPromptView.Default)} + /> + ); + } + if (currentView === EmptyPromptView.API) { + return ( + setView(EmptyPromptView.Default)} + /> + ); + } + + return ( + + +
+ +
+ + } + body={ + +

+ + {i18n.translate( + 'xpack.serverlessSearch.indexManagement.indexDetails.overview.emptyPrompt.body.logstashLink', + { defaultMessage: 'Logstash' } + )} + + ), + beatsLink: ( + + {i18n.translate( + 'xpack.serverlessSearch.indexManagement.indexDetails.overview.emptyPrompt.body.beatsLink', + { defaultMessage: 'Beats' } + )} + + ), + connectorsLink: ( + + {i18n.translate( + 'xpack.serverlessSearch.indexManagement.indexDetails.overview.emptyPrompt.body.connectorsLink', + { defaultMessage: 'connectors' } + )} + + ), + apiCallsLink: ( + + {i18n.translate( + 'xpack.serverlessSearch.indexManagement.indexDetails.overview.emptyPrompt.body.apiCallsLink', + { defaultMessage: 'API calls' } + )} + + ), + }} + /> +

+
+ } + actions={ + + + setView(EmptyPromptView.API)} + > + + + + + setView(EmptyPromptView.NewConnector)} + > + + + + + } + /> +
+ ); +}; diff --git a/x-pack/plugins/serverless_search/public/application/components/index_management/overview_panel.tsx b/x-pack/plugins/serverless_search/public/application/components/index_management/overview_panel.tsx new file mode 100644 index 00000000000000..8b2b54e17d68e0 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/index_management/overview_panel.tsx @@ -0,0 +1,41 @@ +/* + * 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 { EuiSpacer, EuiSplitPanel, EuiTitle } from '@elastic/eui'; + +export interface IndexOverviewPanelProps { + title: React.ReactNode; + footer?: React.ReactNode | React.ReactNode[]; +} + +export const IndexOverviewPanel: React.FC = ({ + title, + footer, + children, +}) => ( + + + +
{title}
+
+ + {children} +
+ {footer && ( + + {footer} + + )} +
+); + +export const IndexOverviewPanelStat: React.FC = ({ children }) => ( + +

{children}

+
+); diff --git a/x-pack/plugins/serverless_search/public/application/components/languages/dotnet.ts b/x-pack/plugins/serverless_search/public/application/components/languages/dotnet.ts index 4f388d2227203d..1ac3641f67fe94 100644 --- a/x-pack/plugins/serverless_search/public/application/components/languages/dotnet.ts +++ b/x-pack/plugins/serverless_search/public/application/components/languages/dotnet.ts @@ -37,6 +37,22 @@ var client = new ElasticsearchClient("${cloudId}", new ApiKey("${apiKey}"));`, }; var response = await client.IndexAsync(doc, "books");`, + ingestDataIndex: ({ apiKey, cloudId, indexName }) => `using System; +using Elastic.Clients.Elasticsearch.Serverless; +using Elastic.Clients.Elasticsearch.Serverless.QueryDsl; + +var client = new ElasticsearchClient("${cloudId}", new ApiKey("${apiKey}")); + +var doc = new Book +{ + Id = "9780553351927", + Name = "Snow Crash", + Author = "Neal Stephenson", + ReleaseDate = new DateTime(1992, 06, 01), + PageCount = 470 +}; + +var response = await client.IndexAsync(doc, "${indexName}");`, buildSearchQuery: `var response = await client.SearchAsync(s => s .Index("books") .From(0) diff --git a/x-pack/plugins/serverless_search/public/application/components/languages/java.ts b/x-pack/plugins/serverless_search/public/application/components/languages/java.ts index 23566fcc67f98b..c247eb978c15ba 100644 --- a/x-pack/plugins/serverless_search/public/application/components/languages/java.ts +++ b/x-pack/plugins/serverless_search/public/application/components/languages/java.ts @@ -66,6 +66,56 @@ for (Book book : books) { BulkResponse result = esClient.bulk(br.build()); +// Log errors, if any +if (result.errors()) { + logger.error("Bulk had errors"); + for (BulkResponseItem item: result.items()) { + if (item.error() != null) { + logger.error(item.error().reason()); + } + } +}`, + ingestDataIndex: ({ apiKey, indexName, url }) => `// URL and API key +String serverUrl = "${url}"; +String apiKey = "${apiKey}"; + +// Create the low-level client +RestClient restClient = RestClient + .builder(HttpHost.create(serverUrl)) + .setDefaultHeaders(new Header[]{ + new BasicHeader("Authorization", "ApiKey " + apiKey) + }) + .build(); + +// Create the transport with a Jackson mapper +ElasticsearchTransport transport = new RestClientTransport( + restClient, new JacksonJsonpMapper()); + +// And create the API client +ElasticsearchClient esClient = new ElasticsearchClient(transport); + +List books = new ArrayList<>(); +books.add(new Book("9780553351927", "Snow Crash", "Neal Stephenson", "1992-06-01", 470)); +books.add(new Book("9780441017225", "Revelation Space", "Alastair Reynolds", "2000-03-15", 585)); +books.add(new Book("9780451524935", "1984", "George Orwell", "1985-06-01", 328)); +books.add(new Book("9781451673319", "Fahrenheit 451", "Ray Bradbury", "1953-10-15", 227)); +books.add(new Book("9780060850524", "Brave New World", "Aldous Huxley", "1932-06-01", 268)); +books.add(new Book("9780385490818", "The Handmaid's Tale", "Margaret Atwood", "1985-06-01", 311)); + +BulkRequest.Builder br = new BulkRequest.Builder(); + +for (Book book : books) { + br.operations(op -> op + .index(idx -> idx + .index("${indexName}") + .id(product.getId()) + .document(book) + ) + ); +} + +BulkResponse result = esClient.bulk(br.build()); + // Log errors, if any if (result.errors()) { logger.error("Bulk had errors"); diff --git a/x-pack/plugins/serverless_search/public/application/constants.ts b/x-pack/plugins/serverless_search/public/application/constants.ts index 6c2f9775c4e047..df8d2fb1ebbc76 100644 --- a/x-pack/plugins/serverless_search/public/application/constants.ts +++ b/x-pack/plugins/serverless_search/public/application/constants.ts @@ -9,3 +9,7 @@ export const API_KEY_PLACEHOLDER = 'your_api_key'; export const ELASTICSEARCH_URL_PLACEHOLDER = 'https://your_deployment_url'; export const CLOUD_ID_PLACEHOLDER = ''; export const INDEX_NAME_PLACEHOLDER = 'index_name'; + +// Paths +export const BASE_CONNECTORS_PATH = 'connectors'; +export const EDIT_CONNECTOR_PATH = `${BASE_CONNECTORS_PATH}/:id`; diff --git a/x-pack/plugins/serverless_search/public/application/hooks/api/use_create_connector.tsx b/x-pack/plugins/serverless_search/public/application/hooks/api/use_create_connector.tsx index fc9c74b94b84b2..fc70e0044529ff 100644 --- a/x-pack/plugins/serverless_search/public/application/hooks/api/use_create_connector.tsx +++ b/x-pack/plugins/serverless_search/public/application/hooks/api/use_create_connector.tsx @@ -8,7 +8,7 @@ import { useEffect } from 'react'; import { generatePath } from 'react-router-dom'; import { useMutation } from '@tanstack/react-query'; import { Connector } from '@kbn/search-connectors'; -import { EDIT_CONNECTOR_PATH } from '../../components/connectors_router'; +import { EDIT_CONNECTOR_PATH } from '../../constants'; import { useKibanaServices } from '../use_kibana'; export const useCreateConnector = () => { @@ -32,9 +32,13 @@ export const useCreateConnector = () => { useEffect(() => { if (isSuccess) { - navigateToUrl(generatePath(EDIT_CONNECTOR_PATH, { id: connector?.id || '' })); + navigateToUrl( + http.basePath.prepend( + `/app/${generatePath(EDIT_CONNECTOR_PATH, { id: connector?.id || '' })}` + ) + ); } - }, [connector, isSuccess, navigateToUrl]); + }, [connector, isSuccess, navigateToUrl, http.basePath]); const createConnector = () => mutate(); return { createConnector, isLoading }; diff --git a/x-pack/plugins/serverless_search/public/application/hooks/api/use_index.tsx b/x-pack/plugins/serverless_search/public/application/hooks/api/use_index.tsx new file mode 100644 index 00000000000000..3ea8ea1588ba5e --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/hooks/api/use_index.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from '@tanstack/react-query'; + +import { FetchIndexResult } from '../../../../common/types'; +import { useKibanaServices } from '../use_kibana'; + +export const useIndex = (id: string) => { + const { http } = useKibanaServices(); + const queryKey = ['fetchIndex', id]; + const result = useQuery({ + queryKey, + queryFn: () => http.fetch(`/internal/serverless_search/index/${id}`), + }); + return { queryKey, ...result }; +}; diff --git a/x-pack/plugins/serverless_search/public/plugin.ts b/x-pack/plugins/serverless_search/public/plugin.ts index f82af8390c9fc9..6c6328931915eb 100644 --- a/x-pack/plugins/serverless_search/public/plugin.ts +++ b/x-pack/plugins/serverless_search/public/plugin.ts @@ -15,7 +15,8 @@ import { import { i18n } from '@kbn/i18n'; import { appIds } from '@kbn/management-cards-navigation'; import { AuthenticatedUser } from '@kbn/security-plugin/common'; -import { createIndexMappingsDocsLinkContent as createIndexMappingsContent } from './application/components/index_mappings_docs_link'; +import { createIndexMappingsDocsLinkContent as createIndexMappingsContent } from './application/components/index_management/index_mappings_docs_link'; +import { createIndexOverviewContent } from './application/components/index_management/index_overview_content'; import { createServerlessSearchSideNavComponent as createComponent } from './layout/nav'; import { docLinks } from '../common/doc_links'; import { @@ -24,7 +25,7 @@ import { ServerlessSearchPluginStart, ServerlessSearchPluginStartDependencies, } from './types'; -import { createIndexOverviewContent } from './application/components/index_documents/documents_tab'; +import { createIndexDocumentsContent } from './application/components/index_documents/documents_tab'; export class ServerlessSearchPlugin implements @@ -98,9 +99,11 @@ export class ServerlessSearchPlugin }); indexManagement?.extensionsService.setIndexMappingsContent(createIndexMappingsContent(core)); indexManagement?.extensionsService.addIndexDetailsTab( + createIndexDocumentsContent(core, services) + ); + indexManagement?.extensionsService.setIndexOverviewContent( createIndexOverviewContent(core, services) ); - return {}; } diff --git a/x-pack/plugins/serverless_search/server/lib/indices/fetch_index.test.ts b/x-pack/plugins/serverless_search/server/lib/indices/fetch_index.test.ts new file mode 100644 index 00000000000000..88edecd4cfd9d3 --- /dev/null +++ b/x-pack/plugins/serverless_search/server/lib/indices/fetch_index.test.ts @@ -0,0 +1,142 @@ +/* + * 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('@kbn/search-connectors', () => ({ + fetchConnectorByIndexName: jest.fn(), +})); + +import { ByteSizeValue } from '@kbn/config-schema'; +import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { fetchConnectorByIndexName } from '@kbn/search-connectors'; + +import { fetchIndex } from './fetch_index'; + +describe('fetch index lib function', () => { + const mockClient = { + indices: { + get: jest.fn(), + stats: jest.fn(), + }, + count: jest.fn(), + }; + const client = () => mockClient as unknown as ElasticsearchClient; + + const indexName = 'search-regular-index'; + const regularIndexResponse = { + 'search-regular-index': { + aliases: {}, + }, + }; + const regularIndexStatsResponse = { + indices: { + 'search-regular-index': { + health: 'green', + size: new ByteSizeValue(108000).toString(), + status: 'open', + total: { + docs: { + count: 100, + deleted: 0, + }, + store: { + size_in_bytes: 108000, + }, + }, + uuid: '83a81e7e-5955-4255-b008-5d6961203f57', + }, + }, + }; + const indexCountResponse = { + count: 100, + }; + const indexConnector = { + foo: 'foo', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return index if all client calls succeed', () => { + mockClient.indices.get.mockResolvedValue({ ...regularIndexResponse }); + mockClient.indices.stats.mockResolvedValue(regularIndexStatsResponse); + mockClient.count.mockResolvedValue(indexCountResponse); + (fetchConnectorByIndexName as unknown as jest.Mock).mockResolvedValue(indexConnector); + + expect(fetchIndex(client(), indexName)).resolves.toMatchObject({ + index: { + aliases: {}, + count: 100, + connector: indexConnector, + stats: regularIndexStatsResponse.indices[indexName], + }, + }); + }); + + it('should throw an error if get index rejects', () => { + const expectedError = new Error('Boom!'); + + mockClient.indices.get.mockRejectedValue(expectedError); + mockClient.indices.stats.mockResolvedValue(regularIndexStatsResponse); + mockClient.count.mockResolvedValue(indexCountResponse); + (fetchConnectorByIndexName as unknown as jest.Mock).mockResolvedValue(indexConnector); + + expect(fetchIndex(client(), indexName)).rejects.toEqual(expectedError); + }); + + it('should return partial data if index stats rejects', () => { + const expectedError = new Error('Boom!'); + + mockClient.indices.get.mockResolvedValue({ ...regularIndexResponse }); + mockClient.indices.stats.mockRejectedValue(expectedError); + mockClient.count.mockResolvedValue(indexCountResponse); + (fetchConnectorByIndexName as unknown as jest.Mock).mockResolvedValue(indexConnector); + + expect(fetchIndex(client(), indexName)).resolves.toMatchObject({ + index: { + aliases: {}, + count: 100, + connector: indexConnector, + }, + }); + }); + + it('should return partial data if index count rejects', () => { + const expectedError = new Error('Boom!'); + + mockClient.indices.get.mockResolvedValue({ ...regularIndexResponse }); + mockClient.indices.stats.mockResolvedValue(regularIndexStatsResponse); + mockClient.count.mockRejectedValue(expectedError); + (fetchConnectorByIndexName as unknown as jest.Mock).mockResolvedValue(indexConnector); + + expect(fetchIndex(client(), indexName)).resolves.toMatchObject({ + index: { + aliases: {}, + count: 0, + connector: indexConnector, + stats: regularIndexStatsResponse.indices[indexName], + }, + }); + }); + + it('should return partial data if fetch connector rejects', () => { + const expectedError = new Error('Boom!'); + + mockClient.indices.get.mockResolvedValue({ ...regularIndexResponse }); + mockClient.indices.stats.mockResolvedValue(regularIndexStatsResponse); + mockClient.count.mockResolvedValue(indexCountResponse); + (fetchConnectorByIndexName as unknown as jest.Mock).mockRejectedValue(expectedError); + + expect(fetchIndex(client(), indexName)).resolves.toMatchObject({ + index: { + aliases: {}, + count: 100, + stats: regularIndexStatsResponse.indices[indexName], + }, + }); + }); +}); diff --git a/x-pack/plugins/serverless_search/server/lib/indices/fetch_index.ts b/x-pack/plugins/serverless_search/server/lib/indices/fetch_index.ts new file mode 100644 index 00000000000000..000eb1c13ea0a3 --- /dev/null +++ b/x-pack/plugins/serverless_search/server/lib/indices/fetch_index.ts @@ -0,0 +1,45 @@ +/* + * 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 { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { fetchConnectorByIndexName } from '@kbn/search-connectors'; + +import { FetchIndexResult } from '../../../common/types'; + +export async function fetchIndex( + client: ElasticsearchClient, + indexName: string +): Promise { + const [indexDataResult, indexStatsResult, indexCountResult, connectorResult] = + await Promise.allSettled([ + client.indices.get({ index: indexName }), + client.indices.stats({ index: indexName }), + client.count({ index: indexName }), + fetchConnectorByIndexName(client, indexName), + ]); + if (indexDataResult.status === 'rejected') { + throw indexDataResult.reason; + } + const indexData = indexDataResult.value; + if (!indexData || !indexData[indexName]) return undefined; + + const index = indexData[indexName]; + const count = indexCountResult.status === 'fulfilled' ? indexCountResult.value.count : 0; + const connector = connectorResult.status === 'fulfilled' ? connectorResult.value : undefined; + const stats = + indexStatsResult.status === 'fulfilled' + ? indexStatsResult.value.indices?.[indexName] + : undefined; + return { + index: { + ...index, + count, + connector, + stats, + }, + }; +} diff --git a/x-pack/plugins/serverless_search/server/routes/indices_routes.ts b/x-pack/plugins/serverless_search/server/routes/indices_routes.ts index 7f165f13218f11..1e54a64642115e 100644 --- a/x-pack/plugins/serverless_search/server/routes/indices_routes.ts +++ b/x-pack/plugins/serverless_search/server/routes/indices_routes.ts @@ -11,6 +11,7 @@ import { schema } from '@kbn/config-schema'; import { fetchSearchResults } from '@kbn/search-index-documents/lib'; import { DEFAULT_DOCS_PER_PAGE } from '@kbn/search-index-documents/types'; import { fetchIndices } from '../lib/indices/fetch_indices'; +import { fetchIndex } from '../lib/indices/fetch_index'; import { RouteDependencies } from '../plugin'; export const registerIndicesRoutes = ({ router, security }: RouteDependencies) => { @@ -75,6 +76,27 @@ export const registerIndicesRoutes = ({ router, security }: RouteDependencies) = } ); + router.get( + { + path: '/internal/serverless_search/index/{indexName}', + validate: { + params: schema.object({ + indexName: schema.string(), + }), + }, + }, + async (context, request, response) => { + const { client } = (await context.core).elasticsearch; + const body = await fetchIndex(client.asCurrentUser, request.params.indexName); + return body + ? response.ok({ + body, + headers: { 'content-type': 'application/json' }, + }) + : response.notFound(); + } + ); + router.post( { path: '/internal/serverless_search/indices/{index_name}/search',