[Serverless Search] Fix connectors bugs (#170679)

## Summary

This includes a few bug fixes and improvements for connectors in
Serverless.

1) Syncs now set a job type so connectors will actually run them
2) Callouts to tell the user that a sync has been scheduled
3) Fix some wonky refetch behavior on updates
4) Consistent error toasts

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sander Philipse 2023-11-07 13:16:57 +01:00 committed by GitHub
parent ea7ae45028
commit 550b3cf08d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 321 additions and 148 deletions

View file

@ -74,7 +74,7 @@ export const startConnectorSync = async (
error: null,
indexed_document_count: 0,
indexed_document_volume: 0,
job_type: jobType,
job_type: jobType || SyncJobType.FULL,
last_seen: null,
metadata: {},
started_at: null,

View file

@ -9,27 +9,30 @@
import { ElasticsearchClient } from '@kbn/core/server';
import { i18n } from '@kbn/i18n';
import { CONNECTORS_INDEX } from '..';
import { CONNECTORS_INDEX, fetchConnectorById } from '..';
import { ConnectorDocument } from '../types/connectors';
import { ConnectorDocument, ConnectorStatus } from '../types/connectors';
export const updateConnectorServiceType = async (
client: ElasticsearchClient,
connectorId: string,
serviceType: string
) => {
const connectorResult = await client.get<ConnectorDocument>({
id: connectorId,
index: CONNECTORS_INDEX,
});
const connector = connectorResult._source;
if (connector) {
const connectorResult = await fetchConnectorById(client, connectorId);
if (connectorResult?.value) {
const result = await client.index<ConnectorDocument>({
document: { ...connector, service_type: serviceType },
document: {
...connectorResult.value,
configuration: {},
service_type: serviceType,
status: ConnectorStatus.NEEDS_CONFIGURATION,
},
id: connectorId,
index: CONNECTORS_INDEX,
if_seq_no: connectorResult.seqNo,
if_primary_term: connectorResult.primaryTerm,
});
await client.indices.refresh({ index: CONNECTORS_INDEX });
return result;
} else {
throw new Error(

View file

@ -6,12 +6,7 @@
*/
import React, { useEffect, useState } from 'react';
import {
Connector,
ConnectorStatus,
pageToPagination,
SyncJobsTable,
} from '@kbn/search-connectors';
import { Connector, ConnectorStatus } from '@kbn/search-connectors';
import {
EuiFlexGroup,
EuiFlexItem,
@ -20,15 +15,14 @@ import {
EuiStepsHorizontalProps,
EuiTabbedContent,
EuiTabbedContentTab,
Pagination,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { CONFIGURATION_LABEL, OVERVIEW_LABEL } from '../../../../../common/i18n_string';
import { ConnectorLinkElasticsearch } from './connector_link';
import { ConnectorConfigFields } from './connector_config_fields';
import { ConnectorIndexName } from './connector_index_name';
import { useSyncJobs } from '../../../hooks/api/use_sync_jobs';
import { ConnectorConfigurationPanels } from './connector_config_panels';
import { ConnectorOverview } from './connector_overview';
interface ConnectorConfigurationProps {
connector: Connector;
@ -91,31 +85,10 @@ export const ConnectorConfiguration: React.FC<ConnectorConfigurationProps> = ({
size: 's',
},
];
const [pagination, setPagination] = useState<Omit<Pagination, 'totalItemCount'>>({
pageIndex: 0,
pageSize: 20,
});
const { data: syncJobsData, isLoading: syncJobsLoading } = useSyncJobs(connector.id, pagination);
const tabs: EuiTabbedContentTab[] = [
{
content: (
<>
<EuiSpacer />
<SyncJobsTable
isLoading={syncJobsLoading}
onPaginate={({ page }) => setPagination({ pageIndex: page.index, pageSize: page.size })}
pagination={
syncJobsData
? pageToPagination(syncJobsData?._meta.page)
: { pageIndex: 0, pageSize: 20, totalItemCount: 0 }
}
syncJobs={syncJobsData?.data || []}
type="content"
/>
</>
),
content: <ConnectorOverview connector={connector} />,
id: 'overview',
name: OVERVIEW_LABEL,
},

View file

@ -7,8 +7,8 @@
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Connector } from '@kbn/search-connectors';
import React, { useEffect, useState } from 'react';
import { Connector, ConnectorStatus } from '@kbn/search-connectors';
import React, { useState } from 'react';
import { useQueryClient, useMutation } from '@tanstack/react-query';
import { isValidIndexName } from '../../../../utils/validate_index_name';
import { SAVE_LABEL } from '../../../../../common/i18n_string';
@ -16,16 +16,19 @@ import { useConnector } from '../../../hooks/api/use_connector';
import { useKibanaServices } from '../../../hooks/use_kibana';
import { ApiKeyPanel } from './api_key_panel';
import { ConnectorIndexNameForm } from './connector_index_name_form';
import { useShowErrorToast } from '../../../hooks/use_error_toast';
import { SyncScheduledCallOut } from './sync_scheduled_callout';
interface ConnectorIndexNameProps {
connector: Connector;
}
export const ConnectorIndexName: React.FC<ConnectorIndexNameProps> = ({ connector }) => {
const { http, notifications } = useKibanaServices();
const { http } = useKibanaServices();
const queryClient = useQueryClient();
const { queryKey } = useConnector(connector.id);
const { data, error, isLoading, isSuccess, mutate, reset } = useMutation({
const showErrorToast = useShowErrorToast();
const { data, isLoading, isSuccess, mutate } = useMutation({
mutationFn: async ({ inputName, sync }: { inputName: string | null; sync?: boolean }) => {
if (inputName && inputName !== connector.index_name) {
const body = { index_name: inputName };
@ -38,25 +41,18 @@ export const ConnectorIndexName: React.FC<ConnectorIndexNameProps> = ({ connecto
}
return inputName;
},
});
useEffect(() => {
if (isSuccess) {
onError: (error) =>
showErrorToast(
error,
i18n.translate('xpack.serverlessSearch.connectors.config.connectorIndexNameError', {
defaultMessage: 'Error updating index name',
})
),
onSuccess: () => {
queryClient.setQueryData(queryKey, { connector: { ...connector, index_name: data } });
queryClient.invalidateQueries(queryKey);
reset();
}
}, [data, isSuccess, connector, queryClient, queryKey, reset]);
useEffect(() => {
if (error) {
notifications.toasts.addError(error as Error, {
title: i18n.translate('xpack.serverlessSearch.connectors.config.connectorIndexNameError', {
defaultMessage: 'Error updating index name',
}),
});
}
}, [error, notifications]);
},
});
const [newIndexName, setNewIndexname] = useState(connector.index_name);
@ -109,7 +105,12 @@ export const ConnectorIndexName: React.FC<ConnectorIndexNameProps> = ({ connecto
<span>
<EuiButton
color="primary"
disabled={!isValidIndexName(newIndexName)}
disabled={
!(
isValidIndexName(newIndexName) &&
[ConnectorStatus.CONFIGURED, ConnectorStatus.CONNECTED].includes(connector.status)
)
}
fill
isLoading={isLoading}
onClick={() => mutate({ inputName: newIndexName, sync: true })}
@ -121,6 +122,12 @@ export const ConnectorIndexName: React.FC<ConnectorIndexNameProps> = ({ connecto
</span>
</EuiFlexItem>
</EuiFlexGroup>
{isSuccess && (
<>
<EuiSpacer />
<SyncScheduledCallOut />
</>
)}
</>
);
};

View file

@ -17,10 +17,12 @@ import {
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ELASTICSEARCH_URL_PLACEHOLDER } from '@kbn/search-api-panels/constants';
import { ConnectorStatus } from '@kbn/search-connectors';
import React from 'react';
import { docLinks } from '../../../../../common/doc_links';
import { useAssetBasePath } from '../../../hooks/use_asset_base_path';
import { useKibanaServices } from '../../../hooks/use_kibana';
interface ConnectorLinkElasticsearchProps {
connectorId: string;
@ -34,6 +36,10 @@ export const ConnectorLinkElasticsearch: React.FC<ConnectorLinkElasticsearchProp
status,
}) => {
const assetBasePath = useAssetBasePath();
const { cloud } = useKibanaServices();
const elasticsearchUrl = cloud?.elasticsearchUrl ?? ELASTICSEARCH_URL_PLACEHOLDER;
return (
<EuiFlexGroup direction="column" alignItems="center" justifyContent="center">
<EuiFlexItem>
@ -111,6 +117,17 @@ export const ConnectorLinkElasticsearch: React.FC<ConnectorLinkElasticsearchProp
{Boolean(serviceType) && <EuiCode>{serviceType}</EuiCode>}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiText size="s">
<strong>elasticsearch.host</strong>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiCode>{elasticsearchUrl}</EuiCode>
</EuiFlexItem>
</EuiFlexGroup>
{status === ConnectorStatus.CREATED && (
<>
<EuiSpacer />

View file

@ -0,0 +1,95 @@
/*
* 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 { EuiButton, EuiSpacer, Pagination } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import {
Connector,
ConnectorStatus,
pageToPagination,
SyncJobsTable,
} from '@kbn/search-connectors';
import { useQueryClient, useMutation } from '@tanstack/react-query';
import React, { useState } from 'react';
import { useConnector } from '../../../hooks/api/use_connector';
import { useSyncJobs } from '../../../hooks/api/use_sync_jobs';
import { useShowErrorToast } from '../../../hooks/use_error_toast';
import { useKibanaServices } from '../../../hooks/use_kibana';
import { SyncScheduledCallOut } from './sync_scheduled_callout';
interface ConnectorOverviewProps {
connector: Connector;
}
export const ConnectorOverview: React.FC<ConnectorOverviewProps> = ({ connector }) => {
const { http } = useKibanaServices();
const queryClient = useQueryClient();
const { queryKey } = useConnector(connector.id);
const showErrorToast = useShowErrorToast();
const { data, isLoading, isSuccess, mutate } = useMutation({
mutationFn: async () => {
await http.post(`/internal/serverless_search/connectors/${connector.id}/sync`);
},
onError: (error) =>
showErrorToast(
error,
i18n.translate('xpack.serverlessSearch.connectors.config.connectorSyncError', {
defaultMessage: 'Error scheduling sync',
})
),
onSuccess: () => {
queryClient.setQueryData(queryKey, { connector: { ...connector, index_name: data } });
queryClient.invalidateQueries(queryKey);
},
});
const [pagination, setPagination] = useState<Omit<Pagination, 'totalItemCount'>>({
pageIndex: 0,
pageSize: 20,
});
const { data: syncJobsData, isLoading: syncJobsLoading } = useSyncJobs(connector.id, pagination);
return (
<>
<EuiSpacer />
<SyncJobsTable
isLoading={syncJobsLoading}
onPaginate={({ page }) => setPagination({ pageIndex: page.index, pageSize: page.size })}
pagination={
syncJobsData
? pageToPagination(syncJobsData?._meta.page)
: { pageIndex: 0, pageSize: 20, totalItemCount: 0 }
}
syncJobs={syncJobsData?.data || []}
type="content"
/>
<EuiSpacer />
<span>
<EuiButton
color="primary"
disabled={
![ConnectorStatus.CONFIGURED, ConnectorStatus.CONNECTED].includes(connector.status)
}
fill
isLoading={isLoading}
onClick={() => mutate()}
>
{i18n.translate('xpack.serverlessSearch.connectors.config.syncLabel', {
defaultMessage: 'Sync',
})}
</EuiButton>
</span>
{isSuccess && (
<>
<EuiSpacer />
<SyncScheduledCallOut />
</>
)}
</>
);
};

View file

@ -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 { EuiCallOut, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
export const SyncScheduledCallOut: React.FC = () => {
return (
<EuiCallOut
color="primary"
iconType="iInCircle"
title={i18n.translate('xpack.serverlessSearch.connectors.syncScheduledTitle', {
defaultMessage: 'A sync has been scheduled',
})}
>
<EuiText>
{i18n.translate('xpack.serverlessSearch.connectors.syncSheduledDescription', {
defaultMessage:
'It may take a minute for this sync to be visible and for the connector to pick it up',
})}
</EuiText>
</EuiCallOut>
);
};

View file

@ -44,7 +44,7 @@ export const EditConnector: React.FC = () => {
application: { navigateToUrl },
} = useKibanaServices();
const { data, isLoading, refetch } = useConnector(id);
const { data, isLoading } = useConnector(id);
if (isLoading) {
<EuiPageTemplate offset={0} grow restrictWidth data-test-subj="svlSearchEditConnectorsPage">
@ -91,7 +91,7 @@ export const EditConnector: React.FC = () => {
<EuiText size="s">{CONNECTOR_LABEL}</EuiText>
<EuiFlexGroup direction="row" justifyContent="spaceBetween">
<EuiFlexItem>
<EditName connectorId={id} name={connector.name} onSuccess={refetch} />
<EditName connector={connector} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
{deleteModalIsOpen && (
@ -151,17 +151,9 @@ export const EditConnector: React.FC = () => {
<EuiPageTemplate.Section>
<EuiFlexGroup direction="row">
<EuiFlexItem grow={1}>
<EditServiceType
connectorId={id}
serviceType={connector.service_type ?? ''}
onSuccess={() => refetch()}
/>
<EditServiceType connector={connector} />
<EuiSpacer />
<EditDescription
connectorId={id}
description={connector.description ?? ''}
onSuccess={refetch}
/>
<EditDescription connector={connector} />
</EuiFlexItem>
<EuiFlexItem grow={2}>
<EuiPanel hasBorder hasShadow={false}>

View file

@ -19,47 +19,51 @@ import {
EuiText,
EuiButtonEmpty,
} from '@elastic/eui';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Connector } from '@kbn/search-connectors';
import { CANCEL_LABEL, EDIT_LABEL, SAVE_LABEL } from '../../../../common/i18n_string';
import { useKibanaServices } from '../../hooks/use_kibana';
import { useConnector } from '../../hooks/api/use_connector';
import { useShowErrorToast } from '../../hooks/use_error_toast';
interface EditDescriptionProps {
connectorId: string;
description: string;
onSuccess: () => void;
connector: Connector;
}
export const EditDescription: React.FC<EditDescriptionProps> = ({
connectorId,
description,
onSuccess,
}) => {
export const EditDescription: React.FC<EditDescriptionProps> = ({ connector }) => {
const [isEditing, setIsEditing] = useState(false);
const [newDescription, setNewDescription] = useState(description);
const [newDescription, setNewDescription] = useState(connector.description || '');
const { http } = useKibanaServices();
const showErrorToast = useShowErrorToast();
const queryClient = useQueryClient();
const { queryKey } = useConnector(connector.id);
useEffect(() => setNewDescription(description), [description]);
useEffect(() => setNewDescription(connector.description || ''), [connector.description]);
const { isLoading, isSuccess, mutate } = useMutation({
const { isLoading, mutate } = useMutation({
mutationFn: async (inputDescription: string) => {
const body = { description: inputDescription };
const result = await http.post(
`/internal/serverless_search/connectors/${connectorId}/description`,
{
body: JSON.stringify(body),
}
);
return result;
await http.post(`/internal/serverless_search/connectors/${connector.id}/description`, {
body: JSON.stringify(body),
});
return inputDescription;
},
onError: (error) =>
showErrorToast(
error,
i18n.translate('xpack.serverlessSearch.connectors.config.connectorDescription', {
defaultMessage: 'Error updating description',
})
),
onSuccess: (successData) => {
queryClient.setQueryData(queryKey, {
connector: { ...connector, description: successData },
});
queryClient.invalidateQueries(queryKey);
setIsEditing(false);
},
});
useEffect(() => {
if (isSuccess) {
setIsEditing(false);
onSuccess();
}
}, [isSuccess, onSuccess]);
return (
<EuiFlexGroup direction="row">
<EuiForm>
@ -80,10 +84,10 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
{isEditing ? (
<EuiFieldText
onChange={(event) => setNewDescription(event.target.value)}
value={newDescription}
value={newDescription || ''}
/>
) : (
<EuiText size="s">{description}</EuiText>
<EuiText size="s">{connector.description}</EuiText>
)}
</EuiFormRow>
</EuiFlexItem>
@ -118,7 +122,7 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
size="s"
isLoading={isLoading}
onClick={() => {
setNewDescription(description);
setNewDescription(connector.description || '');
setIsEditing(false);
}}
>

View file

@ -19,47 +19,58 @@ import {
EuiFormLabel,
EuiSpacer,
} from '@elastic/eui';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Connector } from '@kbn/search-connectors';
import { CANCEL_LABEL, CONNECTOR_LABEL, SAVE_LABEL } from '../../../../common/i18n_string';
import { useKibanaServices } from '../../hooks/use_kibana';
import { useConnector } from '../../hooks/api/use_connector';
import { useShowErrorToast } from '../../hooks/use_error_toast';
interface EditNameProps {
connectorId: string;
name: string;
onSuccess: () => void;
connector: Connector;
}
export const EditName: React.FC<EditNameProps> = ({ connectorId, name, onSuccess }) => {
export const EditName: React.FC<EditNameProps> = ({ connector }) => {
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(name);
const [newName, setNewName] = useState(connector.name || CONNECTOR_LABEL);
const { http } = useKibanaServices();
const showErrorToast = useShowErrorToast();
const queryClient = useQueryClient();
const { queryKey } = useConnector(connector.id);
useEffect(() => setNewName(name), [name]);
useEffect(() => setNewName(connector.name), [connector.name]);
const { isLoading, isSuccess, mutate } = useMutation({
const { isLoading, mutate } = useMutation({
mutationFn: async (inputName: string) => {
const body = { name: inputName };
const result = await http.post(`/internal/serverless_search/connectors/${connectorId}/name`, {
await http.post(`/internal/serverless_search/connectors/${connector.id}/name`, {
body: JSON.stringify(body),
});
return result;
return inputName;
},
onError: (error) =>
showErrorToast(
error,
i18n.translate('xpack.serverlessSearch.connectors.config.connectorNameError', {
defaultMessage: 'Error updating name',
})
),
onSuccess: (successData) => {
queryClient.setQueryData(queryKey, {
connector: { ...connector, service_type: successData },
});
queryClient.invalidateQueries(queryKey);
setIsEditing(false);
},
});
useEffect(() => {
if (isSuccess) {
setIsEditing(false);
onSuccess();
}
}, [isSuccess, onSuccess]);
return (
<EuiFlexGroup direction="row">
{!isEditing ? (
<>
<EuiFlexItem grow={false}>
<EuiTitle>
<h1>{name || CONNECTOR_LABEL}</h1>
<h1>{connector.name || CONNECTOR_LABEL}</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem
@ -117,7 +128,7 @@ export const EditName: React.FC<EditNameProps> = ({ connectorId, name, onSuccess
size="s"
isLoading={isLoading}
onClick={() => {
setNewName(name);
setNewName(connector.name);
setIsEditing(false);
}}
>

View file

@ -6,7 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import React, { useEffect } from 'react';
import React from 'react';
import {
EuiFlexItem,
EuiFlexGroup,
@ -15,23 +15,23 @@ import {
EuiIcon,
EuiSuperSelect,
} from '@elastic/eui';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Connector } from '@kbn/search-connectors';
import { useKibanaServices } from '../../hooks/use_kibana';
import { useConnectorTypes } from '../../hooks/api/use_connector_types';
import { useShowErrorToast } from '../../hooks/use_error_toast';
import { useConnector } from '../../hooks/api/use_connector';
interface EditServiceTypeProps {
connectorId: string;
serviceType: string;
onSuccess: () => void;
connector: Connector;
}
export const EditServiceType: React.FC<EditServiceTypeProps> = ({
connectorId,
serviceType,
onSuccess,
}) => {
export const EditServiceType: React.FC<EditServiceTypeProps> = ({ connector }) => {
const { http } = useKibanaServices();
const { data: connectorTypes } = useConnectorTypes();
const showErrorToast = useShowErrorToast();
const queryClient = useQueryClient();
const { queryKey } = useConnector(connector.id);
const options =
connectorTypes?.connectors.map((connectorType) => ({
@ -51,25 +51,29 @@ export const EditServiceType: React.FC<EditServiceTypeProps> = ({
value: connectorType.serviceType,
})) || [];
const { isLoading, isSuccess, mutate } = useMutation({
const { isLoading, mutate } = useMutation({
mutationFn: async (inputServiceType: string) => {
const body = { service_type: inputServiceType };
const result = await http.post(
`/internal/serverless_search/connectors/${connectorId}/service_type`,
{
body: JSON.stringify(body),
}
);
return result;
await http.post(`/internal/serverless_search/connectors/${connector.id}/service_type`, {
body: JSON.stringify(body),
});
return inputServiceType;
},
onError: (error) =>
showErrorToast(
error,
i18n.translate('xpack.serverlessSearch.connectors.config.connectorServiceTypeError', {
defaultMessage: 'Error updating service type',
})
),
onSuccess: (successData) => {
queryClient.setQueryData(queryKey, {
connector: { ...connector, service_type: successData },
});
queryClient.invalidateQueries(queryKey);
},
});
useEffect(() => {
if (isSuccess) {
onSuccess();
}
}, [isSuccess, onSuccess]);
return (
<EuiForm>
<EuiFormLabel>
@ -81,7 +85,7 @@ export const EditServiceType: React.FC<EditServiceTypeProps> = ({
isLoading={isLoading}
onChange={(event) => mutate(event)}
options={options}
valueOfSelected={serviceType ?? ''}
valueOfSelected={connector.service_type || undefined}
/>
</EuiForm>
);

View file

@ -0,0 +1,18 @@
/*
* 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 { getErrorMessage } from '../../utils/get_error_message';
import { useKibanaServices } from './use_kibana';
export const useShowErrorToast = () => {
const { notifications } = useKibanaServices();
return (error: unknown, errorTitle?: string) =>
notifications.toasts.addError(new Error(getErrorMessage(error)), {
title: errorTitle || getErrorMessage(error),
});
};

View file

@ -0,0 +1,19 @@
/*
* 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): string {
if (typeof error === 'string') {
return error;
}
if (typeof error === 'object') {
return (error as { body: KibanaServerError })?.body?.message || '';
}
return '';
}

View file

@ -35,5 +35,6 @@
"@kbn/react-kibana-context-theme",
"@kbn/search-connectors",
"@kbn/shared-ux-router",
"@kbn/kibana-utils-plugin",
]
}