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',