[Semantic Text UI] Add toast, modal and banner for deployment status (#180246)

In this PR, we added the following items.

- Add a toast for letting the user know that the underlying model is
being deployed
- Start the deployment of trained model if the model deployment has not
been started
- Add a modal to display the current status of trained model deployment
- Show a link to the model management page.
- Create a inference endpoint for default inference_ids if they are
missing
- Display a badge for inference_endpoint
- Show warning if mappingsDefinition is null


Please be aware that currently, we won't be able to save the mapping
using the 'Save mappings' button because the 'semantic_text'
functionality doesn't support 'inference_id'. However, there is ongoing
parallel work in a GitHub
[branch](https://github.com/elastic/elasticsearch/tree/feature/semantic-text)
to enable 'inference_id' in 'semantic_text' for Elasticsearch.

### How to test the changes locally
- Download the elasticsearch changes from GitHub
[branch](https://github.com/elastic/elasticsearch/tree/feature/semantic-text)
- Run the elasticsearch: `./gradlew :run -Drun.license_type=trial`
- Download the changes of this PR in local kibana and do the following
steps
+ Set isSemanticTextEnabled = true in this
[location](https://github.com/elastic/kibana/pull/180246/files#diff-92f4739f8a4a6917951a1b6e1af21a96d54313eaa2b5ce4c0e0553dd2ee11fcaL80)
    +  Run `yarn start`
-  Create an index named 'books' using the following command:

<details>
<summary>Click to expand</summary>

```
PUT books
{
  "mappings": {
    "dynamic_templates": [],
    "properties": {
      "date_published": {
        "type": "date"
      },
      "price": {
        "type": "float"
      },
      "title": {
        "type": "text"
      },
      "attributes": {
        "type": "nested",
        "properties": {
          "authors": {
            "type": "nested",
            "properties": {
              "author_name": {
                "type": "text"
              },
              "author_birthdate": {
                "type": "date"
              }
              // Add more author attributes as needed
            }
          },
          "genres": {
            "type": "nested",
            "properties": {
              "genre_name": {
                "type": "keyword"
              },
              "genre_description": {
                "type": "text"
              }
              // Add more genre attributes as needed
            }
          }
        }
      }
    }
  }
}
```

</details>

- Follow the steps mentioned in this video: 


f1ef71e3-8adf-4bcd-837c-754929fe6f1c

### Screenshots

![Screenshot 2024-04-12 at 9 52
02 AM](ebdec41e-23ee-4622-aed9-aa7ad5b091b8)
![Screenshot 2024-04-12 at 9 52
14 AM](1bec637e-9fe3-4add-b7d6-55fb687f2ce0)
![Screenshot 2024-04-12 at 9 52
29 AM](8c362d25-6d71-4504-ba80-e1b9914dd701)
![Screenshot 2024-04-12 at 9 53
07 AM](b3a0b148-4b3f-4679-bf99-c27bfa27d1fd)
![Screenshot 2024-04-12 at 9 53
46 AM](29e93d39-e84f-4d6f-b3f4-0bfd1405b24d)
![Screenshot 2024-04-12 at 9 54
05 AM](243b87e1-89d3-440d-8848-e30d03c0262f)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Sander Philipse <sander.philipse@elastic.co>
This commit is contained in:
Saikat Sarkar 2024-04-25 13:20:53 -06:00 committed by GitHub
parent b92890a051
commit 19b0543fd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1365 additions and 319 deletions

View file

@ -10,7 +10,7 @@
},
"include": [
"**/*.ts",
"**/*.tsx",
"**/*.tsx"
],
"exclude": [
"target/**/*"

View file

@ -187,6 +187,14 @@ export type InferenceServiceSettings =
model_id: string;
};
}
| {
service: 'elasticsearch';
service_settings: {
num_allocations: number;
num_threads: number;
model_id: string;
};
}
| {
service: 'openai';
service_settings: {

View file

@ -51,6 +51,7 @@ export interface IndexDetailsPageTestBed extends TestBed {
getTreeViewContent: (fieldName: string) => string;
clickToggleViewButton: () => Promise<void>;
isSearchBarDisabled: () => boolean;
isSemanticTextBannerVisible: () => boolean;
};
settings: {
getCodeBlockContent: () => string;
@ -218,6 +219,9 @@ export const setup = async ({
isSearchBarDisabled: () => {
return find('indexDetailsMappingsFieldSearch').prop('disabled');
},
isSemanticTextBannerVisible: () => {
return exists('indexDetailsMappingsSemanticTextBanner');
},
clickAddFieldButton: async () => {
expect(exists('indexDetailsMappingsAddField')).toBe(true);
await act(async () => {

View file

@ -497,6 +497,10 @@ describe('<IndexDetailsPage />', () => {
expect(testBed.actions.mappings.isSearchBarDisabled()).toBe(false);
});
it('semantic text banner is not visible', async () => {
expect(testBed.actions.mappings.isSemanticTextBannerVisible()).toBe(false);
});
it('sets the docs link href from the documentation service', async () => {
const docsLinkHref = testBed.actions.mappings.getDocsLinkHref();
// the url from the mocked docs mock

View file

@ -0,0 +1,47 @@
/*
* 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 { registerTestBed } from '@kbn/test-jest-helpers';
import { act } from 'react-dom/test-utils';
import { SemanticTextBanner } from '../../../public/application/sections/home/index_list/details_page/semantic_text_banner';
describe('When semantic_text is enabled', () => {
const setup = registerTestBed(SemanticTextBanner, {
defaultProps: { isSemanticTextEnabled: true },
memoryRouter: { wrapComponent: false },
});
const { exists, find } = setup();
it('should display the banner', () => {
expect(exists('indexDetailsMappingsSemanticTextBanner')).toBe(true);
});
it('should contain content related to semantic_text', () => {
expect(find('indexDetailsMappingsSemanticTextBanner').text()).toContain(
'semantic_text field type now available!'
);
});
it('should hide the banner if dismiss is clicked', async () => {
await act(async () => {
find('SemanticTextBannerDismissButton').simulate('click');
});
expect(exists('indexDetailsMappingsSemanticTextBanner')).toBe(true);
});
});
describe('When semantic_text is disabled', () => {
const setup = registerTestBed(SemanticTextBanner, {
defaultProps: { isSemanticTextEnabled: false },
memoryRouter: { wrapComponent: false },
});
const { exists } = setup();
it('should not display the banner', () => {
expect(exists('indexDetailsMappingsSemanticTextBanner')).toBe(false);
});
});

View file

@ -0,0 +1,103 @@
/*
* 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 { registerTestBed } from '@kbn/test-jest-helpers';
import { TrainedModelsDeploymentModal } from '../../../public/application/sections/home/index_list/details_page/trained_models_deployment_modal';
import { act } from 'react-dom/test-utils';
const refreshModal = jest.fn();
const setIsModalVisible = jest.fn();
const tryAgainForErrorModal = jest.fn();
const setIsVisibleForErrorModal = jest.fn();
describe('When semantic_text is enabled', () => {
describe('When there is no error in the model deployment', () => {
const setup = registerTestBed(TrainedModelsDeploymentModal, {
defaultProps: {
isSemanticTextEnabled: true,
pendingDeployments: ['.elser-test-3'],
setIsModalVisible,
refreshModal,
},
memoryRouter: { wrapComponent: false },
});
const { exists, find } = setup();
it('should display the modal', () => {
expect(exists('trainedModelsDeploymentModal')).toBe(true);
});
it('should contain content related to semantic_text', () => {
expect(find('trainedModelsDeploymentModalText').text()).toContain(
'Some fields are referencing models'
);
});
it('should call refresh method if refresh button is pressed', async () => {
await act(async () => {
find('confirmModalConfirmButton').simulate('click');
});
expect(refreshModal.mock.calls).toHaveLength(1);
});
it('should call setIsModalVisible method if cancel button is pressed', async () => {
await act(async () => {
find('confirmModalCancelButton').simulate('click');
});
expect(setIsModalVisible.mock.calls).toHaveLength(1);
});
});
describe('When there is error in the model deployment', () => {
const setup = registerTestBed(TrainedModelsDeploymentModal, {
defaultProps: {
isSemanticTextEnabled: true,
pendingDeployments: ['.elser-test-3'],
setIsModalVisible: setIsVisibleForErrorModal,
refreshModal: tryAgainForErrorModal,
errorsInTrainedModelDeployment: ['.elser-test-3'],
},
memoryRouter: { wrapComponent: false },
});
const { exists, find } = setup();
it('should display the modal', () => {
expect(exists('trainedModelsErroredDeploymentModal')).toBe(true);
});
it('should contain content related to semantic_text', () => {
expect(find('trainedModelsErrorDeploymentModalText').text()).toContain(
'There was an error when trying to deploy'
);
});
it("should call refresh method if 'Try again' button is pressed", async () => {
await act(async () => {
find('confirmModalConfirmButton').simulate('click');
});
expect(tryAgainForErrorModal.mock.calls).toHaveLength(1);
});
it('should call setIsModalVisible method if cancel button is pressed', async () => {
await act(async () => {
find('confirmModalCancelButton').simulate('click');
});
expect(setIsVisibleForErrorModal.mock.calls).toHaveLength(1);
});
});
});
describe('When semantic_text is disabled', () => {
const setup = registerTestBed(TrainedModelsDeploymentModal, {
defaultProps: { isSemanticTextEnabled: false },
memoryRouter: { wrapComponent: false },
});
const { exists } = setup();
it('it should not display the modal', () => {
expect(exists('trainedModelsDeploymentModal')).toBe(false);
});
});

View file

@ -15,19 +15,19 @@ import {
FatalErrorsStart,
ScopedHistory,
DocLinksStart,
IUiSettingsClient,
ExecutionContextStart,
HttpSetup,
IUiSettingsClient,
} from '@kbn/core/public';
import type { MlPluginStart } from '@kbn/ml-plugin/public';
import type { SharePluginStart } from '@kbn/share-plugin/public';
import type { SettingsStart } from '@kbn/core-ui-settings-browser';
import { EuiBreadcrumb } from '@elastic/eui';
import type { CloudSetup } from '@kbn/cloud-plugin/public';
import type { ConsolePluginStart } from '@kbn/console-plugin/public';
import { EuiBreadcrumb } from '@elastic/eui';
import type { MlPluginStart } from '@kbn/ml-plugin/public';
import type { SettingsStart } from '@kbn/core-ui-settings-browser';
import { ExtensionsService } from '../services';
import { UiMetricService, NotificationService, HttpService } from './services';
import { HttpService, NotificationService, UiMetricService } from './services';
import { IndexManagementBreadcrumb } from './services/breadcrumbs';
export const AppContext = createContext<AppDependencies | undefined>(undefined);

View file

@ -26,6 +26,7 @@ const { GlobalFlyoutProvider } = GlobalFlyout;
// We provide the minimum deps required to make the tests pass
const appDependencies = {
docLinks: {} as any,
plugins: { ml: {} as any },
} as any;
export const componentTemplatesDependencies = (httpSetup: HttpSetup, coreStart?: CoreStart) => ({

View file

@ -11,6 +11,15 @@ import { componentHelpers, MappingsEditorTestBed } from '../helpers';
const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor;
jest.mock('../../../../component_templates/component_templates_context', () => ({
useComponentTemplatesContext: jest.fn().mockReturnValue({
toasts: {
addError: jest.fn(),
addSuccess: jest.fn(),
},
}),
}));
describe('Mappings editor: other datatype', () => {
/**
* Variable to store the mappings data forwarded to the consumer component

View file

@ -11,6 +11,15 @@ import { componentHelpers, MappingsEditorTestBed } from './helpers';
const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor;
jest.mock('../../../component_templates/component_templates_context', () => ({
useComponentTemplatesContext: jest.fn().mockReturnValue({
toasts: {
addError: jest.fn(),
addSuccess: jest.fn(),
},
}),
}));
describe('Mappings editor: core', () => {
/**
* Variable to store the mappings data forwarded to the consumer component

View file

@ -11,6 +11,15 @@ import { componentHelpers, MappingsEditorTestBed } from './helpers';
const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor;
jest.mock('../../../component_templates/component_templates_context', () => ({
useComponentTemplatesContext: jest.fn().mockReturnValue({
toasts: {
addError: jest.fn(),
addSuccess: jest.fn(),
},
}),
}));
describe('Mappings editor: runtime fields', () => {
/**
* Variable to store the mappings data forwarded to the consumer component

View file

@ -5,23 +5,28 @@
* 2.0.
*/
import React, { useMemo, useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import { GlobalFlyout } from '../../shared_imports';
import { useMappingsState, useDispatch } from '../../mappings_state_context';
import { deNormalize } from '../../lib';
import { EditFieldContainer, EditFieldContainerProps, defaultFlyoutProps } from './fields';
import { useDispatch, useMappingsState } from '../../mappings_state_context';
import { GlobalFlyout } from '../../shared_imports';
import {
defaultFlyoutProps,
EditFieldContainer,
EditFieldContainerProps,
SemanticTextInfo,
} from './fields';
import { DocumentFieldsJsonEditor } from './fields_json_editor';
import { DocumentFieldsTreeEditor } from './fields_tree_editor';
const { useGlobalFlyout } = GlobalFlyout;
interface Props {
searchComponent?: React.ReactElement;
searchResultComponent?: React.ReactElement;
onCancelAddingNewFields?: () => void;
isAddingFields?: boolean;
isSemanticTextEnabled?: boolean;
indexName?: string;
semanticTextInfo?: SemanticTextInfo;
}
export const DocumentFields = React.memo(
({
@ -29,8 +34,7 @@ export const DocumentFields = React.memo(
searchResultComponent,
onCancelAddingNewFields,
isAddingFields,
isSemanticTextEnabled,
indexName,
semanticTextInfo,
}: Props) => {
const { fields, documentFields } = useMappingsState();
const dispatch = useDispatch();
@ -53,8 +57,7 @@ export const DocumentFields = React.memo(
<DocumentFieldsTreeEditor
onCancelAddingNewFields={onCancelAddingNewFields}
isAddingFields={isAddingFields}
isSemanticTextEnabled={isSemanticTextEnabled}
indexName={indexName}
semanticTextInfo={semanticTextInfo}
/>
);

View file

@ -42,8 +42,13 @@ import { useLoadInferenceModels } from '../../../../../services/api';
interface Props {
onChange(value: string): void;
'data-test-subj'?: string;
setValue: (value: string) => void;
}
export const SelectInferenceId = ({ onChange, 'data-test-subj': dataTestSubj }: Props) => {
export const SelectInferenceId = ({
onChange,
'data-test-subj': dataTestSubj,
setValue,
}: Props) => {
const {
core: { application },
docLinks,
@ -142,6 +147,9 @@ export const SelectInferenceId = ({ onChange, 'data-test-subj': dataTestSubj }:
return subscription.unsubscribe;
}, [subscribe, onChange]);
const selectedOptionLabel = options.find((option) => option.checked)?.label;
useEffect(() => {
setValue(selectedOptionLabel ?? 'elser_model_2');
}, [selectedOptionLabel, setValue]);
const [isInferencePopoverVisible, setIsInferencePopoverVisible] = useState<boolean>(false);
const [inferenceEndpointError, setInferenceEndpointError] = useState<string | undefined>(
undefined

View file

@ -5,10 +5,6 @@
* 2.0.
*/
import classNames from 'classnames';
import React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButton,
EuiButtonEmpty,
@ -17,28 +13,38 @@ import {
EuiOutsideClickDetector,
EuiSpacer,
} from '@elastic/eui';
import { FieldWithSemanticTextInfo } from '../../../../types';
import { i18n } from '@kbn/i18n';
import { MlPluginStart } from '@kbn/ml-plugin/public';
import classNames from 'classnames';
import React, { useEffect } from 'react';
import { EUI_SIZE, TYPE_DEFINITION } from '../../../../constants';
import { fieldSerializer } from '../../../../lib';
import { useDispatch } from '../../../../mappings_state_context';
import {
Form,
FormDataProvider,
FormHook,
UseField,
useForm,
useFormData,
} from '../../../../shared_imports';
import { MainType, NormalizedFields } from '../../../../types';
import { Form, FormDataProvider, UseField, useForm, useFormData } from '../../../../shared_imports';
import { Field, MainType, NormalizedFields } from '../../../../types';
import { NameParameter, SubTypeParameter, TypeParameter } from '../../field_parameters';
import { SelectInferenceId } from '../../field_parameters/select_inference_id';
import { ReferenceFieldSelects } from '../../field_parameters/reference_field_selects';
import { SelectInferenceId } from '../../field_parameters/select_inference_id';
import { FieldBetaBadge } from '../field_beta_badge';
import { getRequiredParametersFormForType } from './required_parameters_forms';
import { useSemanticText } from './semantic_text/use_semantic_text';
const formWrapper = (props: any) => <form {...props} />;
export interface InferenceToModelIdMap {
[key: string]: {
trainedModelId?: string;
isDeployed: boolean;
isDeployable: boolean;
defaultInferenceEndpoint: boolean;
};
}
export interface SemanticTextInfo {
isSemanticTextEnabled?: boolean;
indexName?: string;
ml?: MlPluginStart;
setErrorsInTrainedModelDeployment: React.Dispatch<React.SetStateAction<string[]>>;
}
interface Props {
allFields: NormalizedFields['byId'];
isRootLevelField: boolean;
@ -48,23 +54,9 @@ interface Props {
maxNestedDepth?: number;
onCancelAddingNewFields?: () => void;
isAddingFields?: boolean;
isSemanticTextEnabled?: boolean;
indexName?: string;
semanticTextInfo?: SemanticTextInfo;
}
const useFieldEffect = (
form: FormHook,
fieldName: string,
setState: React.Dispatch<React.SetStateAction<string | undefined>>
) => {
const fieldValue = form.getFields()?.[fieldName]?.value as string;
useEffect(() => {
if (fieldValue !== undefined) {
setState(fieldValue);
}
}, [form, fieldValue, setState]);
};
export const CreateField = React.memo(function CreateFieldComponent({
allFields,
isRootLevelField,
@ -74,12 +66,14 @@ export const CreateField = React.memo(function CreateFieldComponent({
maxNestedDepth,
onCancelAddingNewFields,
isAddingFields,
isSemanticTextEnabled,
indexName,
semanticTextInfo,
}: Props) {
const { isSemanticTextEnabled, indexName, ml, setErrorsInTrainedModelDeployment } =
semanticTextInfo ?? {};
const dispatch = useDispatch();
const { form } = useForm<FieldWithSemanticTextInfo>({
const { form } = useForm<Field>({
serializer: fieldSerializer,
options: { stripEmptyFields: false },
});
@ -104,26 +98,24 @@ export const CreateField = React.memo(function CreateFieldComponent({
}
};
const [referenceFieldComboValue, setReferenceFieldComboValue] = useState<string>();
const [nameValue, setNameValue] = useState<string>();
const [inferenceIdComboValue, setInferenceIdComboValue] = useState<string>();
const [semanticFieldType, setSemanticTextFieldType] = useState<string>();
const {
referenceFieldComboValue,
nameValue,
inferenceIdComboValue,
setInferenceValue,
semanticFieldType,
handleSemanticText,
} = useSemanticText({
form,
setErrorsInTrainedModelDeployment,
ml,
});
useFieldEffect(form, 'referenceField', setReferenceFieldComboValue);
useFieldEffect(form, 'inferenceId', setInferenceIdComboValue);
useFieldEffect(form, 'name', setNameValue);
const fieldTypeValue = form.getFields()?.type?.value as Array<{ value: string }>;
useEffect(() => {
if (fieldTypeValue === undefined || fieldTypeValue.length === 0) {
return;
}
setSemanticTextFieldType(
fieldTypeValue[0]?.value === 'semantic_text' ? fieldTypeValue[0].value : undefined
);
}, [form, fieldTypeValue]);
const submitForm = async (e?: React.FormEvent, exitAfter: boolean = false) => {
const submitForm = async (
e?: React.FormEvent,
exitAfter: boolean = false,
clickOutside: boolean = false
) => {
if (e) {
e.preventDefault();
}
@ -132,8 +124,9 @@ export const CreateField = React.memo(function CreateFieldComponent({
if (isValid) {
form.reset();
if (data.type === 'semantic_text') {
dispatch({ type: 'field.addSemanticText', value: data });
if (data.type === 'semantic_text' && !clickOutside) {
handleSemanticText(data);
} else {
dispatch({ type: 'field.add', value: data });
}
@ -152,7 +145,7 @@ export const CreateField = React.memo(function CreateFieldComponent({
cancel();
}
} else {
submitForm(undefined, true);
submitForm(undefined, true, true);
}
};
@ -238,70 +231,66 @@ export const CreateField = React.memo(function CreateFieldComponent({
);
return (
<EuiOutsideClickDetector onOutsideClick={onClickOutside}>
<Form
form={form}
FormWrapper={formWrapper}
onSubmit={submitForm}
data-test-subj="createFieldForm"
>
<div
className={classNames('mappingsEditor__createFieldWrapper', {
'mappingsEditor__createFieldWrapper--toggle':
Boolean(maxNestedDepth) && maxNestedDepth! > 0,
'mappingsEditor__createFieldWrapper--multiField': isMultiField,
})}
style={{
paddingLeft: `${
isMultiField
? paddingLeft! - EUI_SIZE * 1.5 // As there are no "L" bullet list we need to substract some indent
: paddingLeft
}px`,
}}
<>
<EuiOutsideClickDetector onOutsideClick={onClickOutside}>
<Form
form={form}
FormWrapper={formWrapper}
onSubmit={submitForm}
data-test-subj="createFieldForm"
>
<div className="mappingsEditor__createFieldContent">
<EuiFlexGroup gutterSize="s" alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem>{renderFormFields()}</EuiFlexItem>
</EuiFlexGroup>
<div
className={classNames('mappingsEditor__createFieldWrapper', {
'mappingsEditor__createFieldWrapper--toggle':
Boolean(maxNestedDepth) && maxNestedDepth! > 0,
'mappingsEditor__createFieldWrapper--multiField': isMultiField,
})}
style={{
paddingLeft: `${
isMultiField
? paddingLeft! - EUI_SIZE * 1.5 // As there are no "L" bullet list we need to substract some indent
: paddingLeft
}px`,
}}
>
<div className="mappingsEditor__createFieldContent">
{renderFormFields()}
<FormDataProvider pathsToWatch={['type', 'subType']}>
{({ type, subType }) => {
const RequiredParametersForm = getRequiredParametersFormForType(
type?.[0]?.value,
subType?.[0]?.value
);
<FormDataProvider pathsToWatch={['type', 'subType']}>
{({ type, subType }) => {
const RequiredParametersForm = getRequiredParametersFormForType(
type?.[0]?.value,
subType?.[0]?.value
);
if (!RequiredParametersForm) {
return null;
}
if (!RequiredParametersForm) {
return null;
}
const typeDefinition = TYPE_DEFINITION[type?.[0].value as MainType];
const typeDefinition = TYPE_DEFINITION[type?.[0].value as MainType];
return (
<div className="mappingsEditor__createFieldRequiredProps">
{typeDefinition.isBeta ? (
<>
<FieldBetaBadge />
<EuiSpacer size="m" />
</>
) : null}
return (
<div className="mappingsEditor__createFieldRequiredProps">
{typeDefinition.isBeta ? (
<>
<FieldBetaBadge />
<EuiSpacer size="m" />
</>
) : null}
<RequiredParametersForm key={subType ?? type} allFields={allFields} />
</div>
);
}}
</FormDataProvider>
{/* Field inference_id for semantic_text field type */}
<InferenceIdCombo />
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={true} />
<EuiFlexItem grow={false}>{renderFormActions()}</EuiFlexItem>
</EuiFlexGroup>
<RequiredParametersForm key={subType ?? type} allFields={allFields} />
</div>
);
}}
</FormDataProvider>
{/* Field inference_id for semantic_text field type */}
<InferenceIdCombo setValue={setInferenceValue} />
{renderFormActions()}
</div>
</div>
</div>
</Form>
</EuiOutsideClickDetector>
</Form>
</EuiOutsideClickDetector>
</>
);
});
@ -321,7 +310,11 @@ function ReferenceFieldCombo({ indexName }: { indexName?: string }) {
);
}
function InferenceIdCombo() {
interface InferenceProps {
setValue: (value: string) => void;
}
function InferenceIdCombo({ setValue }: InferenceProps) {
const [{ type }] = useFormData({ watch: 'type' });
if (type === undefined || type[0]?.value !== 'semantic_text') {
@ -332,7 +325,7 @@ function InferenceIdCombo() {
<>
<EuiSpacer />
<UseField path="inferenceId">
{(field) => <SelectInferenceId onChange={field.setValue} />}
{(field) => <SelectInferenceId onChange={field.setValue} setValue={setValue} />}
</UseField>
</>
);

View file

@ -0,0 +1,133 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { Field } from '../../../../../types';
import { useSemanticText } from './use_semantic_text';
import { act } from 'react-dom/test-utils';
const mlMock: any = {
mlApi: {
inferenceModels: {
createInferenceEndpoint: jest.fn(),
},
trainedModels: {
startModelAllocation: jest.fn(),
},
},
};
const mockFieldData = {
name: 'name',
type: 'semantic_text',
inferenceId: 'elser_model_2',
} as Field;
const mockDispatch = jest.fn();
jest.mock('../../../../../mappings_state_context', () => ({
useMappingsState: jest.fn().mockReturnValue({
inferenceToModelIdMap: {
e5: {
defaultInferenceEndpoint: false,
isDeployed: false,
isDeployable: true,
trainedModelId: '.multilingual-e5-small',
},
elser_model_2: {
defaultInferenceEndpoint: true,
isDeployed: false,
isDeployable: true,
trainedModelId: '.elser_model_2',
},
},
}),
useDispatch: () => mockDispatch,
}));
jest.mock('../../../../../../component_templates/component_templates_context', () => ({
useComponentTemplatesContext: jest.fn().mockReturnValue({
toasts: {
addError: jest.fn(),
addSuccess: jest.fn(),
},
}),
}));
describe('useSemanticText', () => {
let form: any;
beforeEach(() => {
jest.clearAllMocks();
form = {
getFields: jest.fn().mockReturnValue({
referenceField: { value: 'title' },
name: { value: 'sem' },
type: { value: [{ value: 'semantic_text' }] },
inferenceId: { value: 'e5' },
}),
};
});
it('should populate the values from the form', () => {
const { result } = renderHook(() =>
useSemanticText({ form, setErrorsInTrainedModelDeployment: jest.fn(), ml: mlMock })
);
expect(result.current.referenceFieldComboValue).toBe('title');
expect(result.current.nameValue).toBe('sem');
expect(result.current.inferenceIdComboValue).toBe('e5');
expect(result.current.semanticFieldType).toBe('semantic_text');
});
it('should handle semantic text correctly', async () => {
const { result } = renderHook(() =>
useSemanticText({ form, setErrorsInTrainedModelDeployment: jest.fn(), ml: mlMock })
);
await act(async () => {
result.current.handleSemanticText(mockFieldData);
});
expect(mlMock.mlApi.inferenceModels.createInferenceEndpoint).toHaveBeenCalledWith(
'elser_model_2',
'text_embedding',
{
service: 'elasticsearch',
service_settings: {
num_allocations: 1,
num_threads: 1,
model_id: '.elser_model_2',
},
}
);
expect(mlMock.mlApi.trainedModels.startModelAllocation).toHaveBeenCalledWith('.elser_model_2');
expect(mockDispatch).toHaveBeenCalledWith({
type: 'field.addSemanticText',
value: mockFieldData,
});
});
it('handles errors correctly', async () => {
const mockError = new Error('Test error');
mlMock.mlApi.inferenceModels.createInferenceEndpoint.mockImplementationOnce(() => {
throw mockError;
});
const setErrorsInTrainedModelDeployment = jest.fn();
const { result } = renderHook(() =>
useSemanticText({ form, setErrorsInTrainedModelDeployment, ml: mlMock })
);
await act(async () => {
result.current.handleSemanticText(mockFieldData);
});
expect(setErrorsInTrainedModelDeployment).toHaveBeenCalledWith(expect.any(Function));
});
});

View file

@ -0,0 +1,150 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { MlPluginStart } from '@kbn/ml-plugin/public';
import React, { useEffect, useState } from 'react';
import { useComponentTemplatesContext } from '../../../../../../component_templates/component_templates_context';
import { useDispatch, useMappingsState } from '../../../../../mappings_state_context';
import { FormHook } from '../../../../../shared_imports';
import { Field } from '../../../../../types';
interface UseSemanticTextProps {
form: FormHook<Field, Field>;
ml?: MlPluginStart;
setErrorsInTrainedModelDeployment: React.Dispatch<React.SetStateAction<string[]>> | undefined;
}
export function useSemanticText(props: UseSemanticTextProps) {
const { form, setErrorsInTrainedModelDeployment, ml } = props;
const { inferenceToModelIdMap } = useMappingsState();
const { toasts } = useComponentTemplatesContext();
const dispatch = useDispatch();
const [referenceFieldComboValue, setReferenceFieldComboValue] = useState<string>();
const [nameValue, setNameValue] = useState<string>();
const [inferenceIdComboValue, setInferenceIdComboValue] = useState<string>();
const [semanticFieldType, setSemanticTextFieldType] = useState<string>();
const [inferenceValue, setInferenceValue] = useState<string>('elser_model_2');
const useFieldEffect = (
semanticTextform: FormHook,
fieldName: string,
setState: React.Dispatch<React.SetStateAction<string | undefined>>
) => {
const fieldValue = semanticTextform.getFields()?.[fieldName]?.value;
useEffect(() => {
if (typeof fieldValue === 'string') {
setState(fieldValue);
}
}, [semanticTextform, fieldValue, setState]);
};
useFieldEffect(form, 'referenceField', setReferenceFieldComboValue);
useFieldEffect(form, 'name', setNameValue);
const fieldTypeValue = form.getFields()?.type?.value;
useEffect(() => {
if (!Array.isArray(fieldTypeValue) || fieldTypeValue.length === 0) {
return;
}
setSemanticTextFieldType(
fieldTypeValue[0]?.value === 'semantic_text' ? fieldTypeValue[0].value : undefined
);
}, [form, fieldTypeValue]);
const inferenceId = form.getFields()?.inferenceId?.value;
useEffect(() => {
if (typeof inferenceId === 'string') {
setInferenceIdComboValue(inferenceId);
}
}, [form, inferenceId, inferenceToModelIdMap]);
const handleSemanticText = (data: Field) => {
data.inferenceId = inferenceValue;
if (data.inferenceId === undefined) {
return;
}
const inferenceData = inferenceToModelIdMap?.[data.inferenceId];
if (!inferenceData) {
return;
}
const { trainedModelId, defaultInferenceEndpoint, isDeployed, isDeployable } = inferenceData;
if (trainedModelId && defaultInferenceEndpoint) {
const modelConfig = {
service: 'elasticsearch',
service_settings: {
num_allocations: 1,
num_threads: 1,
model_id: trainedModelId,
},
};
try {
ml?.mlApi?.inferenceModels?.createInferenceEndpoint(
data.inferenceId,
'text_embedding',
modelConfig
);
} catch (error) {
setErrorsInTrainedModelDeployment?.((prevItems) => [...prevItems, trainedModelId]);
toasts?.addError(error.body && error.body.message ? new Error(error.body.message) : error, {
title: i18n.translate(
'xpack.idxMgmt.mappingsEditor.createField.inferenceEndpointCreationErrorTitle',
{
defaultMessage: 'Inference endpoint creation failed',
}
),
});
}
}
if (isDeployable && trainedModelId && !isDeployed) {
try {
ml?.mlApi?.trainedModels.startModelAllocation(trainedModelId);
toasts?.addSuccess({
title: i18n.translate(
'xpack.idxMgmt.mappingsEditor.createField.modelDeploymentStartedNotification',
{
defaultMessage: 'Model deployment started',
}
),
text: i18n.translate(
'xpack.idxMgmt.mappingsEditor.createField.modelDeploymentNotification',
{
defaultMessage: '1 model is being deployed on your ml_node.',
}
),
});
} catch (error) {
setErrorsInTrainedModelDeployment?.((prevItems) => [...prevItems, trainedModelId]);
toasts?.addError(error.body && error.body.message ? new Error(error.body.message) : error, {
title: i18n.translate(
'xpack.idxMgmt.mappingsEditor.createField.modelDeploymentErrorTitle',
{
defaultMessage: 'Model deployment failed',
}
),
});
}
}
dispatch({ type: 'field.addSemanticText', value: data });
};
return {
referenceFieldComboValue,
nameValue,
inferenceIdComboValue,
semanticFieldType,
handleSemanticText,
setInferenceValue,
};
}

View file

@ -98,11 +98,14 @@ function FieldListItemComponent(
isExpanded,
path,
} = field;
// When there aren't any "child" fields (the maxNestedDepth === 0), there is no toggle icon on the left of any field.
// For that reason, we need to compensate and substract some indent to left align on the page.
const substractIndentAmount = maxNestedDepth === 0 ? CHILD_FIELD_INDENT_SIZE * 0.5 : 0;
const indent = treeDepth * CHILD_FIELD_INDENT_SIZE - substractIndentAmount;
const isSemanticText = source.type === 'semantic_text';
const indentCreateField =
(treeDepth + 1) * CHILD_FIELD_INDENT_SIZE +
LEFT_PADDING_SIZE_FIELD_ITEM_WRAPPER -
@ -288,6 +291,12 @@ function FieldListItemComponent(
</EuiBadge>
</EuiFlexItem>
{isSemanticText && (
<EuiFlexItem grow={false}>
<EuiBadge color="hollow">{source.inference_id}</EuiBadge>
</EuiFlexItem>
)}
{isShadowed && (
<EuiFlexItem grow={false}>
<EuiToolTip content={i18nTexts.fieldIsShadowedLabel}>

View file

@ -5,25 +5,23 @@
* 2.0.
*/
import React, { useMemo, useCallback } from 'react';
import { EuiButtonEmpty, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useMemo } from 'react';
import { useMappingsState, useDispatch } from '../../mappings_state_context';
import { FieldsList, CreateField } from './fields';
import { useDispatch, useMappingsState } from '../../mappings_state_context';
import { CreateField, FieldsList, SemanticTextInfo } from './fields';
interface Props {
onCancelAddingNewFields?: () => void;
isAddingFields?: boolean;
isSemanticTextEnabled?: boolean;
indexName?: string;
semanticTextInfo?: SemanticTextInfo;
}
export const DocumentFieldsTreeEditor = ({
onCancelAddingNewFields,
isAddingFields,
isSemanticTextEnabled = false,
indexName,
semanticTextInfo,
}: Props) => {
const dispatch = useDispatch();
const {
@ -53,8 +51,7 @@ export const DocumentFieldsTreeEditor = ({
isRootLevelField
onCancelAddingNewFields={onCancelAddingNewFields}
isAddingFields={isAddingFields}
isSemanticTextEnabled={isSemanticTextEnabled}
indexName={indexName}
semanticTextInfo={semanticTextInfo}
/>
);
};

View file

@ -8,34 +8,34 @@
import { v4 as uuidv4 } from 'uuid';
import {
DataType,
Fields,
Field,
NormalizedFields,
NormalizedField,
FieldMeta,
MainType,
SubType,
ChildFieldName,
ParameterName,
ComboBoxOption,
DataType,
Field,
FieldMeta,
Fields,
GenericObject,
RuntimeFields,
MainType,
NormalizedField,
NormalizedFields,
NormalizedRuntimeFields,
ParameterName,
RuntimeFields,
SubType,
} from '../types';
import {
SUB_TYPE_MAP_TO_MAIN,
MAIN_DATA_TYPE_DEFINITION,
MAX_DEPTH_DEFAULT_EDITOR,
PARAMETERS_DEFINITION,
SUB_TYPE_MAP_TO_MAIN,
TYPE_DEFINITION,
TYPE_NOT_ALLOWED_MULTIFIELD,
TYPE_ONLY_ALLOWED_AT_ROOT_LEVEL,
TYPE_DEFINITION,
MAIN_DATA_TYPE_DEFINITION,
} from '../constants';
import { FieldConfig } from '../shared_imports';
import { TreeItem } from '../components/tree';
import { FieldConfig } from '../shared_imports';
export const getUniqueId = () => uuidv4();

View file

@ -25,13 +25,13 @@ import {
MappingsTemplates,
RuntimeFields,
} from './types';
import { extractMappingsDefinition } from './lib';
import { useDispatch, useMappingsState } from './mappings_state_context';
import { useMappingsStateListener } from './use_state_listener';
import { useConfig } from './config_context';
import { DocLinksStart } from './shared_imports';
import { DocumentFieldsHeader } from './components/document_fields/document_fields_header';
import { SearchResult } from './components/document_fields/search_fields';
import { parseMappings } from '../../shared/parse_mappings';
type TabName = 'fields' | 'runtimeFields' | 'advanced' | 'templates';
@ -56,50 +56,10 @@ export interface Props {
export const MappingsEditor = React.memo(
({ onChange, value, docLinks, indexSettings, esNodesPlugins }: Props) => {
const { parsedDefaultValue, multipleMappingsDeclared } =
useMemo<MappingsEditorParsedMetadata>(() => {
const mappingsDefinition = extractMappingsDefinition(value);
if (mappingsDefinition === null) {
return { multipleMappingsDeclared: true };
}
const {
_source,
_meta,
_routing,
_size,
dynamic,
properties,
runtime,
/* eslint-disable @typescript-eslint/naming-convention */
numeric_detection,
date_detection,
dynamic_date_formats,
dynamic_templates,
/* eslint-enable @typescript-eslint/naming-convention */
} = mappingsDefinition;
const parsed = {
configuration: {
_source,
_meta,
_routing,
_size,
dynamic,
numeric_detection,
date_detection,
dynamic_date_formats,
},
fields: properties,
templates: {
dynamic_templates,
},
runtime,
};
return { parsedDefaultValue: parsed, multipleMappingsDeclared: false };
}, [value]);
const { parsedDefaultValue, multipleMappingsDeclared } = useMemo<MappingsEditorParsedMetadata>(
() => parseMappings(value),
[value]
);
/**
* Hook that will listen to:
* 1. "value" prop changes in order to reset the mappings editor

View file

@ -54,6 +54,7 @@ export const StateProvider: React.FC = ({ children }) => {
term: '',
result: [],
},
inferenceToModelIdMap: {},
};
const [state, dispatch] = useReducer(reducer, initialState);

View file

@ -16,14 +16,7 @@ import {
shouldDeleteChildFieldsAfterTypeChange,
updateFieldsPathAfterFieldNameChange,
} from './lib';
import {
Action,
Field,
FieldWithSemanticTextInfo,
NormalizedField,
NormalizedFields,
State,
} from './types';
import { Action, Field, NormalizedField, NormalizedFields, State } from './types';
export const addFieldToState = (field: Field, state: State): State => {
const updatedFields = { ...state.fields };
@ -324,7 +317,7 @@ export const reducer = (state: State, action: Action): State => {
return addFieldToState(action.value, state);
}
case 'field.addSemanticText': {
const addTexFieldWithCopyToActionValue: FieldWithSemanticTextInfo = {
const addTexFieldWithCopyToActionValue: Field = {
name: action.value.referenceField as string,
type: 'text',
copy_to: [action.value.name],
@ -333,7 +326,7 @@ export const reducer = (state: State, action: Action): State => {
// Add text field to state with copy_to of semantic_text field
let updatedState = addFieldToState(addTexFieldWithCopyToActionValue, state);
const addSemanticTextFieldActionValue: FieldWithSemanticTextInfo = {
const addSemanticTextFieldActionValue: Field = {
name: action.value.name,
inference_id: action.value.inferenceId,
type: 'semantic_text',
@ -621,5 +614,11 @@ export const reducer = (state: State, action: Action): State => {
isValid: action.value,
};
}
case 'inferenceToModelIdMap.update': {
return {
...state,
inferenceToModelIdMap: action.value.inferenceToModelIdMap,
};
}
}
};

View file

@ -9,8 +9,8 @@ import { ReactNode } from 'react';
import { GenericObject } from './mappings_editor';
import { FieldConfig, RuntimeField } from '../shared_imports';
import { PARAMETERS_DEFINITION } from '../constants';
import { FieldConfig, RuntimeField } from '../shared_imports';
export interface DataTypeDefinition {
label: string;
@ -183,6 +183,9 @@ interface FieldBasic {
subType?: SubType;
properties?: { [key: string]: Omit<Field, 'name'> };
fields?: { [key: string]: Omit<Field, 'name'> };
referenceField?: string;
inferenceId?: string;
inference_id?: string;
// other* exist together as a holder of types that the mappings editor does not yet know about but
// enables the user to create mappings with them.
@ -195,11 +198,6 @@ type FieldParams = {
export type Field = FieldBasic & Partial<FieldParams>;
export interface FieldWithSemanticTextInfo extends Field {
referenceField?: string;
inferenceId?: string;
}
export interface FieldMeta {
childFieldsName: ChildFieldName | undefined;
canHaveChildFields: boolean;

View file

@ -5,10 +5,10 @@
* 2.0.
*/
import { InferenceToModelIdMap } from '../components/document_fields/fields';
import { FormHook, OnFormUpdateArg, RuntimeField } from '../shared_imports';
import {
Field,
FieldWithSemanticTextInfo,
NormalizedFields,
NormalizedRuntimeField,
NormalizedRuntimeFields,
@ -98,20 +98,22 @@ export interface State {
result: SearchResult[];
};
templates: TemplatesFormState;
inferenceToModelIdMap?: InferenceToModelIdMap;
}
export type Action =
| { type: 'editor.replaceMappings'; value: { [key: string]: any } }
| {
type: 'inferenceToModelIdMap.update';
value: { inferenceToModelIdMap?: InferenceToModelIdMap };
}
| { type: 'configuration.update'; value: Partial<ConfigurationFormState> }
| { type: 'configuration.save'; value: MappingsConfiguration }
| { type: 'templates.update'; value: Partial<State['templates']> }
| { type: 'templates.save'; value: MappingsTemplates }
| { type: 'fieldForm.update'; value: OnFormUpdateArg<any> }
| { type: 'field.add'; value: Field }
| {
type: 'field.addSemanticText';
value: FieldWithSemanticTextInfo;
}
| { type: 'field.addSemanticText'; value: Field }
| { type: 'field.remove'; value: string }
| { type: 'field.edit'; value: Field }
| { type: 'field.toggleExpand'; value: { fieldId: string; isExpanded?: boolean } }

View file

@ -10,7 +10,6 @@ import SemVer from 'semver/classes/semver';
import { CoreSetup, CoreStart, ScopedHistory } from '@kbn/core/public';
import { ManagementAppMountParams } from '@kbn/management-plugin/public';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import { CloudSetup } from '@kbn/cloud-plugin/public';
import { UIM_APP_NAME } from '../../common/constants';
import { PLUGIN } from '../../common/constants/plugin';

View file

@ -5,11 +5,13 @@
* 2.0.
*/
import React, { FunctionComponent, useCallback, useMemo, useState } from 'react';
import {
EuiAccordion,
EuiButton,
EuiCallOut,
EuiCodeBlock,
EuiFilterButton,
EuiFilterGroup,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
@ -19,41 +21,38 @@ import {
EuiSpacer,
EuiText,
EuiTitle,
EuiEmptyPrompt,
useGeneratedHtmlId,
EuiFilterGroup,
EuiFilterButton,
EuiCallOut,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react';
import { Index } from '../../../../../../common';
import { useDetailsPageMappingsModelManagement } from '../../../../../hooks/use_details_page_mappings_model_management';
import { useAppContext } from '../../../../app_context';
import { DocumentFields } from '../../../../components/mappings_editor/components';
import { DocumentFieldsSearch } from '../../../../components/mappings_editor/components/document_fields/document_fields_search';
import { FieldsList } from '../../../../components/mappings_editor/components/document_fields/fields';
import { SearchResult } from '../../../../components/mappings_editor/components/document_fields/search_fields';
import {
extractMappingsDefinition,
searchFields,
} from '../../../../components/mappings_editor/lib';
import { MultipleMappingsWarning } from '../../../../components/mappings_editor/components/multiple_mappings_warning';
import { deNormalize, searchFields } from '../../../../components/mappings_editor/lib';
import { MappingsEditorParsedMetadata } from '../../../../components/mappings_editor/mappings_editor';
import {
useDispatch,
useMappingsState,
} from '../../../../components/mappings_editor/mappings_state_context';
import { useMappingsStateListener } from '../../../../components/mappings_editor/use_state_listener';
import { documentationService } from '../../../../services';
import { DocumentFields } from '../../../../components/mappings_editor/components';
import { deNormalize } from '../../../../components/mappings_editor/lib';
import { updateIndexMappings } from '../../../../services/api';
import { notificationService } from '../../../../services/notification';
import {
NormalizedField,
NormalizedFields,
State,
} from '../../../../components/mappings_editor/types';
import { useMappingsStateListener } from '../../../../components/mappings_editor/use_state_listener';
import { documentationService } from '../../../../services';
import { updateIndexMappings } from '../../../../services/api';
import { notificationService } from '../../../../services/notification';
import { SemanticTextBanner } from './semantic_text_banner';
import { TrainedModelsDeploymentModal } from './trained_models_deployment_modal';
import { parseMappings } from '../../../../shared/parse_mappings';
const getFieldsFromState = (state: State) => {
const getField = (fieldId: string) => {
@ -64,6 +63,7 @@ const getFieldsFromState = (state: State) => {
};
return fields();
};
export const DetailsPageMappingsContent: FunctionComponent<{
index: Index;
data: string;
@ -82,7 +82,20 @@ export const DetailsPageMappingsContent: FunctionComponent<{
const {
services: { extensionsService },
core: { getUrlForApp },
plugins: { ml },
url,
} = useAppContext();
const [errorsInTrainedModelDeployment, setErrorsInTrainedModelDeployment] = useState<string[]>(
[]
);
const semanticTextInfo = {
isSemanticTextEnabled,
indexName: index.name,
ml,
setErrorsInTrainedModelDeployment,
};
const state = useMappingsState();
const dispatch = useDispatch();
@ -107,50 +120,16 @@ export const DetailsPageMappingsContent: FunctionComponent<{
setIsJSONVisible(!isJSONVisible);
};
const mappingsDefinition = extractMappingsDefinition(jsonData);
const { parsedDefaultValue } = useMemo<MappingsEditorParsedMetadata>(() => {
if (mappingsDefinition === null) {
return { multipleMappingsDeclared: true };
}
const {
_source,
_meta,
_routing,
_size,
dynamic,
properties,
runtime,
/* eslint-disable @typescript-eslint/naming-convention */
numeric_detection,
date_detection,
dynamic_date_formats,
dynamic_templates,
/* eslint-enable @typescript-eslint/naming-convention */
} = mappingsDefinition;
const parsed = {
configuration: {
_source,
_meta,
_routing,
_size,
dynamic,
numeric_detection,
date_detection,
dynamic_date_formats,
},
fields: properties,
templates: {
dynamic_templates,
},
runtime,
};
return { parsedDefaultValue: parsed, multipleMappingsDeclared: false };
}, [mappingsDefinition]);
const { parsedDefaultValue, multipleMappingsDeclared } = useMemo<MappingsEditorParsedMetadata>(
() => parseMappings(jsonData),
[jsonData]
);
useMappingsStateListener({ value: parsedDefaultValue, status: 'disabled' });
const { fetchInferenceToModelIdMap, pendingDeployments } = useDetailsPageMappingsModelManagement(
state.fields,
state.inferenceToModelIdMap
);
const onCancelAddingNewFields = useCallback(() => {
setAddingFields(!isAddingFields);
@ -189,9 +168,49 @@ export const DetailsPageMappingsContent: FunctionComponent<{
});
}, [dispatch, isAddingFields, state]);
const [isModalVisible, setIsModalVisible] = useState(false);
useEffect(() => {
if (!isSemanticTextEnabled) {
return;
}
const fetchData = async () => {
await fetchInferenceToModelIdMap();
};
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const refreshModal = useCallback(async () => {
try {
if (!isSemanticTextEnabled) {
return;
}
await fetchInferenceToModelIdMap();
setIsModalVisible(pendingDeployments.length > 0);
} catch (exception) {
setSaveMappingError(exception.message);
}
}, [fetchInferenceToModelIdMap, isSemanticTextEnabled, pendingDeployments]);
const updateMappings = useCallback(async () => {
try {
const { error } = await updateIndexMappings(indexName, deNormalize(state.fields));
if (isSemanticTextEnabled) {
await fetchInferenceToModelIdMap();
if (pendingDeployments.length > 0) {
setIsModalVisible(true);
return;
}
}
const denormalizedFields = deNormalize(state.fields);
const { error } = await updateIndexMappings(indexName, denormalizedFields);
if (!error) {
notificationService.showSuccessToast(
@ -206,7 +225,8 @@ export const DetailsPageMappingsContent: FunctionComponent<{
} catch (exception) {
setSaveMappingError(exception.message);
}
}, [state.fields, indexName, refetchMapping]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.fields, pendingDeployments]);
const onSearchChange = useCallback(
(value: string) => {
@ -273,27 +293,8 @@ export const DetailsPageMappingsContent: FunctionComponent<{
);
const treeViewBlock = (
<>
{mappingsDefinition === null ? (
<EuiEmptyPrompt
color="danger"
iconType="error"
title={
<h2>
<FormattedMessage
id="xpack.idxMgmt.indexDetails.mappings.invalidMappingKeysErrorMessageTitle"
defaultMessage="Unable to load the mapping"
/>
</h2>
}
body={
<h2>
<FormattedMessage
id="xpack.idxMgmt.indexDetails.mappings.invalidMappingKeysErrorMessageBody"
defaultMessage="The mapping contains invalid keys. Please provide a mapping with valid keys."
/>
</h2>
}
/>
{multipleMappingsDeclared ? (
<MultipleMappingsWarning />
) : searchTerm !== '' ? (
searchResultComponent
) : (
@ -449,6 +450,9 @@ export const DetailsPageMappingsContent: FunctionComponent<{
</EuiFilterGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem grow={true}>
<SemanticTextBanner isSemanticTextEnabled={isSemanticTextEnabled} />
</EuiFlexItem>
{errorSavingMappings}
{isAddingFields && (
<EuiFlexItem grow={false}>
@ -488,14 +492,12 @@ export const DetailsPageMappingsContent: FunctionComponent<{
<DocumentFields
onCancelAddingNewFields={onCancelAddingNewFields}
isAddingFields={isAddingFields}
isSemanticTextEnabled={isSemanticTextEnabled}
indexName={indexName}
semanticTextInfo={semanticTextInfo}
/>
) : (
<DocumentFields
isAddingFields={isAddingFields}
isSemanticTextEnabled={isSemanticTextEnabled}
indexName={indexName}
semanticTextInfo={semanticTextInfo}
/>
)}
</EuiPanel>
@ -517,6 +519,16 @@ export const DetailsPageMappingsContent: FunctionComponent<{
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
{isModalVisible && (
<TrainedModelsDeploymentModal
pendingDeployments={pendingDeployments}
errorsInTrainedModelDeployment={errorsInTrainedModelDeployment}
isSemanticTextEnabled={isSemanticTextEnabled}
setIsModalVisible={setIsModalVisible}
refreshModal={refreshModal}
url={url}
/>
)}
</>
);
};

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useState } from 'react';
interface SemanticTextBannerProps {
isSemanticTextEnabled: boolean;
}
export function SemanticTextBanner({ isSemanticTextEnabled }: SemanticTextBannerProps) {
const [isSemanticTextBannerDisplayable, setIsSemanticTextBannerDisplayable] =
useState<boolean>(true);
return isSemanticTextBannerDisplayable && isSemanticTextEnabled ? (
<>
<EuiPanel color="success" data-test-subj="indexDetailsMappingsSemanticTextBanner">
<EuiFlexGroup>
<EuiFlexItem>
<EuiText size="m" color="success">
<FormattedMessage
id="xpack.idxMgmt.indexDetails.mappings.semanticTextBanner.description"
defaultMessage="{label} Add a field to your mapping and choose 'semantic_text' to get started.'"
values={{
label: (
<strong>
<FormattedMessage
id="xpack.idxMgmt.indexDetails.mappings.semanticTextBanner.semanticTextFieldAvailable"
defaultMessage="semantic_text field type now available!"
/>
</strong>
),
}}
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
onClick={() => setIsSemanticTextBannerDisplayable(false)}
data-test-subj="SemanticTextBannerDismissButton"
>
<FormattedMessage
id="xpack.idxMgmt.indexDetails.mappings.semanticTextBanner.dismiss"
defaultMessage="Dismiss"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</>
) : null;
}

View file

@ -0,0 +1,186 @@
/*
* 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 { EuiConfirmModal, useGeneratedHtmlId, EuiHealth } from '@elastic/eui';
import React from 'react';
import { EuiLink } from '@elastic/eui';
import { useEffect, useState } from 'react';
import type { SharePluginStart } from '@kbn/share-plugin/public';
import { i18n } from '@kbn/i18n';
interface SemanticTextProps {
isSemanticTextEnabled: boolean;
setIsModalVisible: (isVisible: boolean) => void;
refreshModal: () => void;
pendingDeployments: Array<string | undefined>;
errorsInTrainedModelDeployment: string[];
url?: SharePluginStart['url'];
}
const ML_APP_LOCATOR = 'ML_APP_LOCATOR';
const TRAINED_MODELS_MANAGE = 'trained_models';
export function TrainedModelsDeploymentModal({
isSemanticTextEnabled,
setIsModalVisible,
refreshModal,
pendingDeployments = [],
errorsInTrainedModelDeployment = [],
url,
}: SemanticTextProps) {
const modalTitleId = useGeneratedHtmlId();
const closeModal = () => setIsModalVisible(false);
const [mlManagementPageUrl, setMlManagementPageUrl] = useState<string>('');
useEffect(() => {
let isCancelled = false;
const mlLocator = url?.locators.get(ML_APP_LOCATOR);
const generateUrl = async () => {
if (mlLocator) {
const mlURL = await mlLocator.getUrl({
page: TRAINED_MODELS_MANAGE,
});
if (!isCancelled) {
setMlManagementPageUrl(mlURL);
}
}
};
generateUrl();
return () => {
isCancelled = true;
};
}, [url]);
const ErroredDeployments = pendingDeployments.filter(
(deployment) => deployment !== undefined && errorsInTrainedModelDeployment.includes(deployment)
);
const PendingModelsDeploymentModal = () => {
const pendingDeploymentsList = pendingDeployments.map((deployment, index) => (
<li key={index}>
<EuiHealth textSize="xs" color="warning">
{deployment}
</EuiHealth>
</li>
));
return (
<EuiConfirmModal
aria-labelledby={modalTitleId}
style={{ width: 600 }}
title={i18n.translate(
'xpack.idxMgmt.indexDetails.trainedModelsDeploymentModal.titleLabel',
{
defaultMessage: 'Models still deploying',
}
)}
titleProps={{ id: modalTitleId }}
onCancel={closeModal}
onConfirm={refreshModal}
cancelButtonText={i18n.translate(
'xpack.idxMgmt.indexDetails.trainedModelsDeploymentModal.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
)}
confirmButtonText={i18n.translate(
'xpack.idxMgmt.indexDetails.trainedModelsDeploymentModal.refreshButtonLabel',
{
defaultMessage: 'Refresh',
}
)}
defaultFocusedButton="confirm"
data-test-subj="trainedModelsDeploymentModal"
>
<p data-test-subj="trainedModelsDeploymentModalText">
{i18n.translate(
'xpack.idxMgmt.indexDetails.trainedModelsDeploymentModal.textAboutDeploymentsNotCompleted',
{
defaultMessage:
'Some fields are referencing models that have not yet completed deployment. Deployment may take a few minutes to complete.',
}
)}
</p>
<ul style={{ listStyleType: 'none' }}>{pendingDeploymentsList}</ul>
<EuiLink href={mlManagementPageUrl} target="_blank">
{i18n.translate(
'xpack.idxMgmt.indexDetails.trainedModelsDeploymentModal.textTrainedModelManagementLink',
{
defaultMessage: 'Go to Trained Model Management',
}
)}
</EuiLink>
</EuiConfirmModal>
);
};
const ErroredModelsDeploymentModal = () => {
const pendingDeploymentsList = pendingDeployments.map((deployment, index) => (
<li key={index}>
<EuiHealth textSize="xs" color="danger">
{deployment}
</EuiHealth>
</li>
));
return (
<EuiConfirmModal
aria-labelledby={modalTitleId}
style={{ width: 600 }}
title={i18n.translate(
'xpack.idxMgmt.indexDetails.trainedModelsDeploymentModal.deploymentErrorTitle',
{
defaultMessage: 'Models could not be deployed',
}
)}
titleProps={{ id: modalTitleId }}
onCancel={closeModal}
onConfirm={refreshModal}
cancelButtonText={i18n.translate(
'xpack.idxMgmt.indexDetails.trainedModelsDeploymentModal.deploymentErrorCancelButtonLabel',
{
defaultMessage: 'Cancel',
}
)}
confirmButtonText={i18n.translate(
'xpack.idxMgmt.indexDetails.trainedModelsDeploymentModal.deploymentErrorTryAgainButtonLabel',
{
defaultMessage: 'Try again',
}
)}
defaultFocusedButton="confirm"
data-test-subj="trainedModelsErroredDeploymentModal"
>
<p data-test-subj="trainedModelsErrorDeploymentModalText">
{i18n.translate(
'xpack.idxMgmt.indexDetails.trainedModelsDeploymentModal.deploymentErrorText',
{
defaultMessage: 'There was an error when trying to deploy the following models.',
}
)}
</p>
<ul style={{ listStyleType: 'none' }}>{pendingDeploymentsList}</ul>
<EuiLink href={mlManagementPageUrl} target="_blank">
{i18n.translate(
'xpack.idxMgmt.indexDetails.trainedModelsDeploymentModal.deploymentErrorTrainedModelManagementLink',
{
defaultMessage: 'Go to Trained Model Management',
}
)}
</EuiLink>
</EuiConfirmModal>
);
};
return isSemanticTextEnabled ? (
ErroredDeployments.length > 0 ? (
<ErroredModelsDeploymentModal />
) : (
<PendingModelsDeploymentModal />
)
) : null;
}

View file

@ -442,6 +442,13 @@ export function updateIndexMappings(indexName: string, newFields: Fields) {
});
}
export function getInferenceModels() {
return sendRequest({
path: `${API_BASE_PATH}/inference/all`,
method: 'get',
});
}
export function useLoadInferenceModels() {
return useRequest<InferenceAPIConfigResponse[]>({
path: `${API_BASE_PATH}/inference/all`,

View file

@ -0,0 +1,59 @@
/*
* 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 { extractMappingsDefinition } from '../components/mappings_editor/lib';
import { MappingsEditorParsedMetadata } from '../components/mappings_editor/mappings_editor';
interface MappingsDefinition {
[key: string]: any;
}
export const parseMappings = (
value: MappingsDefinition | undefined
): MappingsEditorParsedMetadata => {
const mappingsDefinition = extractMappingsDefinition(value);
if (mappingsDefinition === null) {
return { multipleMappingsDeclared: true };
}
const {
_source,
_meta,
_routing,
_size,
dynamic,
properties,
runtime,
/* eslint-disable @typescript-eslint/naming-convention */
numeric_detection,
date_detection,
dynamic_date_formats,
dynamic_templates,
/* eslint-enable @typescript-eslint/naming-convention */
} = mappingsDefinition;
const parsed = {
configuration: {
_source,
_meta,
_routing,
_size,
dynamic,
numeric_detection,
date_detection,
dynamic_date_formats,
},
fields: properties,
templates: {
dynamic_templates,
},
runtime,
};
return { parsedDefaultValue: parsed, multipleMappingsDeclared: false };
};

View file

@ -0,0 +1,146 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { InferenceToModelIdMap } from '../application/components/mappings_editor/components/document_fields/fields';
import { NormalizedFields } from '../application/components/mappings_editor/types';
import { useDetailsPageMappingsModelManagement } from './use_details_page_mappings_model_management';
jest.mock('../application/app_context', () => ({
useAppContext: () => ({
plugins: {
ml: {
mlApi: {
trainedModels: {
getTrainedModelStats: jest.fn().mockResolvedValue({
trained_model_stats: [
{
model_id: '.elser_model_2',
deployment_stats: {
deployment_id: 'elser_model_2',
model_id: '.elser_model_2',
state: 'started',
},
},
],
}),
},
},
},
},
}),
}));
jest.mock('../application/services/api', () => ({
getInferenceModels: jest.fn().mockResolvedValue({
data: [
{
model_id: 'e5',
task_type: 'text_embedding',
service: 'elasticsearch',
service_settings: {
num_allocations: 1,
num_threads: 1,
model_id: '.multilingual-e5-small',
},
task_settings: {},
},
],
}),
}));
jest.mock('../application/components/mappings_editor/mappings_state_context', () => ({
useDispatch: () => mockDispatch,
}));
const mockDispatch = jest.fn();
const fields = {
byId: {
'88ebcfdb-19b7-4458-9ea2-9488df54453d': {
id: '88ebcfdb-19b7-4458-9ea2-9488df54453d',
isMultiField: false,
source: {
name: 'title',
type: 'text',
copy_to: ['semantic'],
},
path: ['title'],
nestedDepth: 0,
childFieldsName: 'fields',
canHaveChildFields: false,
hasChildFields: false,
canHaveMultiFields: true,
hasMultiFields: false,
isExpanded: false,
},
'c5d86c82-ea07-4457-b469-3ffd4b96db81': {
id: 'c5d86c82-ea07-4457-b469-3ffd4b96db81',
isMultiField: false,
source: {
name: 'semantic',
inference_id: 'elser_model_2',
type: 'semantic_text',
},
path: ['semantic'],
nestedDepth: 0,
childFieldsName: 'fields',
canHaveChildFields: false,
hasChildFields: false,
canHaveMultiFields: true,
hasMultiFields: false,
isExpanded: false,
},
},
aliases: {},
rootLevelFields: ['88ebcfdb-19b7-4458-9ea2-9488df54453d', 'c5d86c82-ea07-4457-b469-3ffd4b96db81'],
maxNestedDepth: 2,
} as NormalizedFields;
const inferenceToModelIdMap = {
elser_model_2: {
trainedModelId: '.elser_model_2',
isDeployed: true,
isDeployable: true,
defaultInferenceEndpoint: false,
},
e5: {
trainedModelId: '.multilingual-e5-small',
isDeployed: true,
isDeployable: true,
defaultInferenceEndpoint: false,
},
} as InferenceToModelIdMap;
describe('useDetailsPageMappingsModelManagement', () => {
it('should call the dispatch with correct parameters', async () => {
const { result } = renderHook(() =>
useDetailsPageMappingsModelManagement(fields, inferenceToModelIdMap)
);
await result.current.fetchInferenceToModelIdMap();
const expectedMap = {
type: 'inferenceToModelIdMap.update',
value: {
inferenceToModelIdMap: {
e5: {
defaultInferenceEndpoint: false,
isDeployed: false,
isDeployable: true,
trainedModelId: '.multilingual-e5-small',
},
elser_model_2: {
defaultInferenceEndpoint: true,
isDeployed: true,
isDeployable: true,
trainedModelId: '.elser_model_2',
},
},
},
};
expect(mockDispatch).toHaveBeenCalledWith(expectedMap);
});
});

View file

@ -0,0 +1,127 @@
/*
* 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 { InferenceStatsResponse } from '@kbn/ml-plugin/public/application/services/ml_api_service/trained_models';
import type { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils';
import { useCallback, useMemo } from 'react';
import { useAppContext } from '../application/app_context';
import { InferenceToModelIdMap } from '../application/components/mappings_editor/components/document_fields/fields';
import { deNormalize } from '../application/components/mappings_editor/lib';
import { useDispatch } from '../application/components/mappings_editor/mappings_state_context';
import { NormalizedFields } from '../application/components/mappings_editor/types';
import { getInferenceModels } from '../application/services/api';
interface InferenceModel {
data: InferenceAPIConfigResponse[];
}
type DeploymentStatusType = Record<string, 'deployed' | 'not_deployed'>;
const getCustomInferenceIdMap = (
deploymentStatsByModelId: DeploymentStatusType,
models?: InferenceModel
) => {
return models?.data.reduce<InferenceToModelIdMap>((inferenceMap, model) => {
const inferenceId = model.model_id;
const trainedModelId =
'model_id' in model.service_settings ? model.service_settings.model_id : '';
inferenceMap[inferenceId] = {
trainedModelId,
isDeployable: model.service === 'elser' || model.service === 'elasticsearch',
isDeployed: deploymentStatsByModelId[trainedModelId] === 'deployed',
defaultInferenceEndpoint: false,
};
return inferenceMap;
}, {});
};
const getTrainedModelStats = (modelStats?: InferenceStatsResponse): DeploymentStatusType => {
return (
modelStats?.trained_model_stats.reduce<DeploymentStatusType>((acc, modelStat) => {
if (modelStat.model_id) {
acc[modelStat.model_id] =
modelStat?.deployment_stats?.state === 'started' ? 'deployed' : 'not_deployed';
}
return acc;
}, {}) || {}
);
};
const getDefaultInferenceIds = (deploymentStatsByModelId: DeploymentStatusType) => {
return {
elser_model_2: {
trainedModelId: '.elser_model_2',
isDeployable: true,
isDeployed: deploymentStatsByModelId['.elser_model_2'] === 'deployed',
defaultInferenceEndpoint: true,
},
e5: {
trainedModelId: '.multilingual-e5-small',
isDeployable: true,
isDeployed: deploymentStatsByModelId['.multilingual-e5-small'] === 'deployed',
defaultInferenceEndpoint: true,
},
};
};
export const useDetailsPageMappingsModelManagement = (
fields: NormalizedFields,
inferenceToModelIdMap?: InferenceToModelIdMap
) => {
const {
plugins: { ml },
} = useAppContext();
const dispatch = useDispatch();
const fetchInferenceModelsAndTrainedModelStats = useCallback(async () => {
const inferenceModels = await getInferenceModels();
const trainedModelStats = await ml?.mlApi?.trainedModels.getTrainedModelStats();
return { inferenceModels, trainedModelStats };
}, [ml]);
const fetchInferenceToModelIdMap = useCallback(async () => {
const { inferenceModels, trainedModelStats } = await fetchInferenceModelsAndTrainedModelStats();
const deploymentStatsByModelId = getTrainedModelStats(trainedModelStats);
const defaultInferenceIds = getDefaultInferenceIds(deploymentStatsByModelId);
const modelIdMap = getCustomInferenceIdMap(deploymentStatsByModelId, inferenceModels);
dispatch({
type: 'inferenceToModelIdMap.update',
value: { inferenceToModelIdMap: { ...defaultInferenceIds, ...modelIdMap } },
});
}, [dispatch, fetchInferenceModelsAndTrainedModelStats]);
const inferenceIdsInPendingList = useMemo(() => {
return Object.values(deNormalize(fields))
.filter((field) => field.type === 'semantic_text' && field.inference_id)
.map((field) => field.inference_id);
}, [fields]);
const pendingDeployments = useMemo(() => {
return inferenceIdsInPendingList
.map((inferenceId) => {
if (inferenceId === undefined) {
return undefined;
}
const trainedModelId = inferenceToModelIdMap?.[inferenceId]?.trainedModelId ?? '';
return trainedModelId && !inferenceToModelIdMap?.[inferenceId]?.isDeployed
? trainedModelId
: undefined;
})
.filter((trainedModelId) => !!trainedModelId);
}, [inferenceIdsInPendingList, inferenceToModelIdMap]);
return {
pendingDeployments,
fetchInferenceToModelIdMap,
fetchInferenceModelsAndTrainedModelStats,
};
};

View file

@ -11,6 +11,7 @@ import { ManagementSetup } from '@kbn/management-plugin/public';
import { MlPluginStart } from '@kbn/ml-plugin/public';
import { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
export interface SetupDependencies {
fleet?: unknown;
usageCollection: UsageCollectionSetup;

View file

@ -5,10 +5,4 @@
* 2.0.
*/
import { RouteDependencies } from '../../../types';
import { registerGetAllRoute } from './register_get_route';
export function registerInferenceModelRoutes(dependencies: RouteDependencies) {
registerGetAllRoute(dependencies);
}
export { registerInferenceModelRoutes } from './register_inference_route';

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { RouteDependencies } from '../../../types';
import { registerGetAllRoute } from './register_get_route';
export function registerInferenceModelRoutes(dependencies: RouteDependencies) {
registerGetAllRoute(dependencies);
}

View file

@ -50,9 +50,7 @@
"@kbn/inference_integration_flyout",
"@kbn/ml-plugin",
"@kbn/ml-error-utils",
"@kbn/react-kibana-context-render",
"@kbn/react-kibana-context-render"
],
"exclude": [
"target/**/*"
]
"exclude": ["target/**/*"]
}