[ML] Adds ability to deploy trained models for data frame analytics jobs (#162537)

## Summary

Related issue: https://github.com/elastic/kibana/issues/161026

This PR adds a 'Deploy model' action in Machine Learning > Model
Management > Trained models.

Action in list:
<img width="1392" alt="image"
src="65978519-a2c2-41ff-8709-9a868587c5af">

Details step:
<img width="1406" alt="image"
src="eba9ebf3-ab69-4e8e-9c02-120982d94373">

Configure processor:
<img width="1403" alt="image"
src="39ae977f-163f-4a86-9034-817abf123ac2">

Configure processor edit:
<img width="1394" alt="image"
src="8dcee306-baef-4871-8ca7-668c77fa95cb">

Configure processor additional:
<img width="1396" alt="image"
src="268c7cf8-19eb-4f29-afbb-8d5e8a0c8598">

Handle failures:
<img width="1310" alt="image"
src="a692c892-6730-4a93-8c27-2acc417505e1">

<img width="1310" alt="image"
src="37087120-b9e6-4399-a2ce-012a17d57a52">

<img width="1308" alt="image"
src="57d840a8-41bf-40ff-9702-39ca7facf610">

Test:
<img width="1397" alt="image"
src="2a07b358-1945-47c7-8ba3-82942f3cf359">

Create:
<img width="1404" alt="image"
src="8bd163b3-64df-4611-924e-06f0e8df1330">

Create success:
<img width="1404" alt="image"
src="be9c7732-9635-465f-9e03-28f11e157711">



### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Melissa Alvarez 2023-08-08 14:01:28 -04:00 committed by GitHub
parent c7053d4d4b
commit 327448b726
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 2651 additions and 4 deletions

View file

@ -319,6 +319,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => {
overview: `${KIBANA_DOCS}upgrade-assistant.html`,
batchReindex: `${KIBANA_DOCS}batch-start-resume-reindex.html`,
remoteReindex: `${ELASTICSEARCH_DOCS}docs-reindex.html#reindex-from-remote`,
reindexWithPipeline: `${ELASTICSEARCH_DOCS}docs-reindex.html#reindex-with-an-ingest-pipeline`,
},
rollupJobs: `${KIBANA_DOCS}data-rollups.html`,
elasticsearch: {

View file

@ -298,6 +298,7 @@ export interface DocLinks {
readonly overview: string;
readonly batchReindex: string;
readonly remoteReindex: string;
readonly reindexWithPipeline: string;
};
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;

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 React, { FC, useMemo, useState } from 'react';
import {
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiFlyoutFooter,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { extractErrorProperties } from '@kbn/ml-error-utils';
import { ModelItem } from '../../model_management/models_list';
import type { AddInferencePipelineSteps } from './types';
import { ADD_INFERENCE_PIPELINE_STEPS } from './constants';
import { AddInferencePipelineFooter } from './components/add_inference_pipeline_footer';
import { AddInferencePipelineHorizontalSteps } from './components/add_inference_pipeline_horizontal_steps';
import { getInitialState, getModelType } from './state';
import { PipelineDetails } from './components/pipeline_details';
import { ProcessorConfiguration } from './components/processor_configuration';
import { OnFailureConfiguration } from './components/on_failure_configuration';
import { TestPipeline } from './components/test_pipeline';
import { ReviewAndCreatePipeline } from './components/review_and_create_pipeline';
import { useMlApiContext } from '../../contexts/kibana';
import { getPipelineConfig } from './get_pipeline_config';
import { validateInferencePipelineConfigurationStep } from './validation';
import type { MlInferenceState, InferenceModelTypes } from './types';
import { useFetchPipelines } from './hooks/use_fetch_pipelines';
export interface AddInferencePipelineFlyoutProps {
onClose: () => void;
model: ModelItem;
}
export const AddInferencePipelineFlyout: FC<AddInferencePipelineFlyoutProps> = ({
onClose,
model,
}) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const initialState = useMemo(() => getInitialState(model), [model.model_id]);
const [formState, setFormState] = useState<MlInferenceState>(initialState);
const [step, setStep] = useState<AddInferencePipelineSteps>(ADD_INFERENCE_PIPELINE_STEPS.DETAILS);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
const {
trainedModels: { createInferencePipeline },
} = useMlApiContext();
const modelType = getModelType(model);
const createPipeline = async () => {
setFormState({ ...formState, creatingPipeline: true });
try {
await createInferencePipeline(formState.pipelineName, getPipelineConfig(formState));
setFormState({
...formState,
pipelineCreated: true,
creatingPipeline: false,
pipelineError: undefined,
});
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
const errorProperties = extractErrorProperties(e);
setFormState({
...formState,
creatingPipeline: false,
pipelineError: errorProperties.message ?? e.message,
});
}
};
const pipelineNames = useFetchPipelines();
const handleConfigUpdate = (configUpdate: Partial<MlInferenceState>) => {
setFormState({ ...formState, ...configUpdate });
};
const { pipelineName: pipelineNameError, targetField: targetFieldError } = useMemo(() => {
const errors = validateInferencePipelineConfigurationStep(
formState.pipelineName,
pipelineNames
);
return errors;
}, [pipelineNames, formState.pipelineName]);
const sourceIndex = useMemo(
() =>
Array.isArray(model.metadata?.analytics_config.source.index)
? model.metadata?.analytics_config.source.index.join()
: model.metadata?.analytics_config.source.index,
// eslint-disable-next-line react-hooks/exhaustive-deps
[model?.model_id]
);
return (
<EuiFlyout onClose={onClose} className="mlTrainedModelsInferencePipelineFlyout" size="l">
<EuiFlyoutHeader>
<EuiTitle size="m">
<h3>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.title',
{
defaultMessage: 'Deploy analytics model',
}
)}
</h3>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<AddInferencePipelineHorizontalSteps
step={step}
setStep={setStep}
isDetailsStepValid={pipelineNameError === undefined && targetFieldError === undefined}
isConfigureProcessorStepValid={hasUnsavedChanges === false}
/>
<EuiSpacer size="m" />
{step === ADD_INFERENCE_PIPELINE_STEPS.DETAILS && (
<PipelineDetails
handlePipelineConfigUpdate={handleConfigUpdate}
pipelineName={formState.pipelineName}
pipelineNameError={pipelineNameError}
pipelineDescription={formState.pipelineDescription}
modelId={model.model_id}
targetField={formState.targetField}
targetFieldError={targetFieldError}
/>
)}
{step === ADD_INFERENCE_PIPELINE_STEPS.CONFIGURE_PROCESSOR && model && (
<ProcessorConfiguration
condition={formState.condition}
fieldMap={formState.fieldMap}
handleAdvancedConfigUpdate={handleConfigUpdate}
inferenceConfig={formState.inferenceConfig}
modelInferenceConfig={model.inference_config}
modelInputFields={model.input ?? []}
modelType={modelType as InferenceModelTypes}
setHasUnsavedChanges={setHasUnsavedChanges}
tag={formState.tag}
/>
)}
{step === ADD_INFERENCE_PIPELINE_STEPS.ON_FAILURE && (
<OnFailureConfiguration
ignoreFailure={formState.ignoreFailure}
takeActionOnFailure={formState.takeActionOnFailure}
handleAdvancedConfigUpdate={handleConfigUpdate}
onFailure={formState.onFailure}
/>
)}
{step === ADD_INFERENCE_PIPELINE_STEPS.TEST && (
<TestPipeline sourceIndex={sourceIndex} state={formState} />
)}
{step === ADD_INFERENCE_PIPELINE_STEPS.CREATE && (
<ReviewAndCreatePipeline
inferencePipeline={getPipelineConfig(formState)}
modelType={modelType}
pipelineName={formState.pipelineName}
pipelineCreated={formState.pipelineCreated}
pipelineError={formState.pipelineError}
/>
)}
</EuiFlyoutBody>
<EuiFlyoutFooter className="mlTrainedModelsInferencePipelineFlyoutFooter">
<AddInferencePipelineFooter
onClose={onClose}
onCreate={createPipeline}
step={step}
setStep={setStep}
isDetailsStepValid={pipelineNameError === undefined && targetFieldError === undefined}
isConfigureProcessorStepValid={hasUnsavedChanges === false}
pipelineCreated={formState.pipelineCreated}
creatingPipeline={formState.creatingPipeline}
/>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -0,0 +1,96 @@
/*
* 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, { FC, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { AddInferencePipelineSteps } from '../types';
import {
BACK_BUTTON_LABEL,
CANCEL_BUTTON_LABEL,
CLOSE_BUTTON_LABEL,
CONTINUE_BUTTON_LABEL,
} from '../constants';
import { getSteps } from '../get_steps';
interface Props {
isDetailsStepValid: boolean;
isConfigureProcessorStepValid: boolean;
pipelineCreated: boolean;
creatingPipeline: boolean;
step: AddInferencePipelineSteps;
onClose: () => void;
onCreate: () => void;
setStep: React.Dispatch<React.SetStateAction<AddInferencePipelineSteps>>;
}
export const AddInferencePipelineFooter: FC<Props> = ({
isDetailsStepValid,
isConfigureProcessorStepValid,
creatingPipeline,
pipelineCreated,
onClose,
onCreate,
step,
setStep,
}) => {
const { nextStep, previousStep, isContinueButtonEnabled } = useMemo(
() => getSteps(step, isDetailsStepValid, isConfigureProcessorStepValid),
[isDetailsStepValid, isConfigureProcessorStepValid, step]
);
return (
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClose}>
{pipelineCreated ? CLOSE_BUTTON_LABEL : CANCEL_BUTTON_LABEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem />
<EuiFlexItem grow={false}>
{previousStep !== undefined && pipelineCreated === false ? (
<EuiButtonEmpty
flush="both"
iconType="arrowLeft"
onClick={() => setStep(previousStep as AddInferencePipelineSteps)}
>
{BACK_BUTTON_LABEL}
</EuiButtonEmpty>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{nextStep !== undefined ? (
<EuiButton
iconType="arrowRight"
iconSide="right"
onClick={() => setStep(nextStep as AddInferencePipelineSteps)}
disabled={!isContinueButtonEnabled}
fill
>
{CONTINUE_BUTTON_LABEL}
</EuiButton>
) : (
<EuiButton
color="success"
disabled={!isContinueButtonEnabled || creatingPipeline || pipelineCreated}
fill
onClick={onCreate}
isLoading={creatingPipeline}
>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.addInferencePipelineModal.footer.create',
{
defaultMessage: 'Create pipeline',
}
)}
</EuiButton>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,118 @@
/*
* 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, { FC, memo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiStepsHorizontal, EuiStepsHorizontalProps } from '@elastic/eui';
import type { AddInferencePipelineSteps } from '../types';
import { ADD_INFERENCE_PIPELINE_STEPS } from '../constants';
const steps = Object.values(ADD_INFERENCE_PIPELINE_STEPS);
interface Props {
step: AddInferencePipelineSteps;
setStep: React.Dispatch<React.SetStateAction<AddInferencePipelineSteps>>;
isDetailsStepValid: boolean;
isConfigureProcessorStepValid: boolean;
}
export const AddInferencePipelineHorizontalSteps: FC<Props> = memo(
({ step, setStep, isDetailsStepValid, isConfigureProcessorStepValid }) => {
const currentStepIndex = steps.findIndex((s) => s === step);
const navSteps: EuiStepsHorizontalProps['steps'] = [
{
// Details
onClick: () => setStep(ADD_INFERENCE_PIPELINE_STEPS.DETAILS),
status: isDetailsStepValid ? 'complete' : 'incomplete',
title: i18n.translate(
'xpack.ml.inferencePipeline.content.indices.transforms.addInferencePipelineModal.steps.details.title',
{
defaultMessage: 'Details',
}
),
},
{
// Processor configuration
onClick: () => {
if (!isDetailsStepValid) return;
setStep(ADD_INFERENCE_PIPELINE_STEPS.CONFIGURE_PROCESSOR);
},
status:
isDetailsStepValid && isConfigureProcessorStepValid && currentStepIndex > 1
? 'complete'
: 'incomplete',
title: i18n.translate(
'xpack.ml.inferencePipeline.content.indices.transforms.addInferencePipelineModal.steps.configureProcessor.title',
{
defaultMessage: 'Configure processor',
}
),
},
{
// handle failures
onClick: () => {
if (!isDetailsStepValid) return;
setStep(ADD_INFERENCE_PIPELINE_STEPS.ON_FAILURE);
},
status: currentStepIndex > 2 ? 'complete' : 'incomplete',
title: i18n.translate(
'xpack.ml.inferencePipeline.content.indices.transforms.addInferencePipelineModal.steps.handleFailures.title',
{
defaultMessage: 'Handle failures',
}
),
},
{
// Test
onClick: () => {
if (!isConfigureProcessorStepValid || !isDetailsStepValid) return;
setStep(ADD_INFERENCE_PIPELINE_STEPS.TEST);
},
status: currentStepIndex > 3 ? 'complete' : 'incomplete',
title: i18n.translate(
'xpack.ml.trainedModels.content.indices.transforms.addInferencePipelineModal.steps.test.title',
{
defaultMessage: 'Test (Optional)',
}
),
},
{
// Review and Create
onClick: () => {
if (!isConfigureProcessorStepValid) return;
setStep(ADD_INFERENCE_PIPELINE_STEPS.CREATE);
},
status: isDetailsStepValid && isConfigureProcessorStepValid ? 'incomplete' : 'disabled',
title: i18n.translate(
'xpack.ml.inferencePipeline.content.indices.transforms.addInferencePipelineModal.steps.create.title',
{
defaultMessage: 'Create',
}
),
},
];
switch (step) {
case ADD_INFERENCE_PIPELINE_STEPS.DETAILS:
navSteps[0].status = 'current';
break;
case ADD_INFERENCE_PIPELINE_STEPS.CONFIGURE_PROCESSOR:
navSteps[1].status = 'current';
break;
case ADD_INFERENCE_PIPELINE_STEPS.ON_FAILURE:
navSteps[2].status = 'current';
break;
case ADD_INFERENCE_PIPELINE_STEPS.TEST:
navSteps[3].status = 'current';
break;
case ADD_INFERENCE_PIPELINE_STEPS.CREATE:
navSteps[4].status = 'current';
break;
}
return <EuiStepsHorizontal steps={navSteps} />;
}
);

View file

@ -0,0 +1,162 @@
/*
* 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, { FC, useState, memo, useMemo } from 'react';
import {
EuiAccordion,
EuiFlexGroup,
EuiFieldText,
EuiFlexItem,
EuiFormRow,
EuiLink,
EuiPanel,
EuiTextArea,
htmlIdGenerator,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { AdditionalSettings, MlInferenceState } from '../types';
import { SaveChangesButton } from './save_changes_button';
import { useMlKibana } from '../../../contexts/kibana';
interface Props {
condition?: string;
tag?: string;
handleAdvancedConfigUpdate: (configUpdate: Partial<MlInferenceState>) => void;
}
export const AdditionalAdvancedSettings: FC<Props> = memo(
({ handleAdvancedConfigUpdate, condition, tag }) => {
const [additionalSettings, setAdditionalSettings] = useState<
Partial<AdditionalSettings> | undefined
>(condition || tag ? { condition, tag } : undefined);
const {
services: {
docLinks: { links },
},
} = useMlKibana();
const handleAdditionalSettingsChange = (settingsChange: Partial<AdditionalSettings>) => {
setAdditionalSettings({ ...additionalSettings, ...settingsChange });
};
const accordionId = useMemo(() => htmlIdGenerator()(), []);
const additionalSettingsUpdated = useMemo(
() => additionalSettings?.tag !== tag || additionalSettings?.condition !== condition,
[additionalSettings, tag, condition]
);
const updateAdditionalSettings = () => {
handleAdvancedConfigUpdate({ ...additionalSettings });
};
return (
<EuiAccordion
id={accordionId}
buttonContent={
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow={false}>
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.additionalSettingsLabel"
defaultMessage="Additional settings"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{additionalSettingsUpdated ? (
<SaveChangesButton
onClick={updateAdditionalSettings}
disabled={additionalSettings === undefined}
/>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
}
>
<EuiPanel color="subdued">
<EuiFlexGroup>
{/* CONDITION */}
<EuiFlexItem>
<EuiFormRow
fullWidth
label={
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.conditionLabel"
defaultMessage="Condition (optional)"
/>
}
helpText={
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.conditionHelpText"
defaultMessage="This condition must be written as a {painlessDocs} script. If provided, this inference processor only runs when condition is true."
values={{
painlessDocs: (
<EuiLink
external
target="_blank"
href={links.scriptedFields.painlessWalkthrough}
>
Painless
</EuiLink>
),
}}
/>
}
>
<EuiTextArea
aria-label={i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.conditionAriaLabel',
{ defaultMessage: 'Optional condition for running the processor' }
)}
value={additionalSettings?.condition ?? ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
handleAdditionalSettingsChange({ condition: e.target.value })
}
/>
</EuiFormRow>
</EuiFlexItem>
{/* TAG */}
<EuiFlexItem>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiFormRow
fullWidth
label={
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.TagLabel"
defaultMessage="Tag (optional)"
/>
}
helpText={
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.tagHelpText"
defaultMessage="Identifier for the processor. Useful for debugging and metrics."
/>
}
>
<EuiFieldText
value={additionalSettings?.tag ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleAdditionalSettingsChange({ tag: e.target.value })
}
aria-label={i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.tagAriaLabel',
{ defaultMessage: 'Optional tag identifier for the processor' }
)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiAccordion>
);
}
);

View file

@ -0,0 +1,270 @@
/*
* 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, { FC, useState, memo } from 'react';
import {
EuiButtonEmpty,
EuiCode,
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiLink,
EuiSpacer,
EuiSwitch,
EuiSwitchEvent,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { CodeEditor } from '@kbn/kibana-react-plugin/public';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { SaveChangesButton } from './save_changes_button';
import type { MlInferenceState } from '../types';
import { getDefaultOnFailureConfiguration } from '../state';
import { CANCEL_EDIT_MESSAGE, EDIT_MESSAGE } from '../constants';
import { useMlKibana } from '../../../contexts/kibana';
import { isValidJson } from '../../../../../common/util/validation_utils';
interface Props {
handleAdvancedConfigUpdate: (configUpdate: Partial<MlInferenceState>) => void;
ignoreFailure: boolean;
onFailure: MlInferenceState['onFailure'];
takeActionOnFailure: MlInferenceState['takeActionOnFailure'];
}
export const OnFailureConfiguration: FC<Props> = memo(
({ handleAdvancedConfigUpdate, ignoreFailure, onFailure, takeActionOnFailure }) => {
const {
services: {
docLinks: { links },
},
} = useMlKibana();
const [editOnFailure, setEditOnFailure] = useState<boolean>(false);
const [isOnFailureValid, setIsOnFailureValid] = useState<boolean>(false);
const [onFailureString, setOnFailureString] = useState<string>(
JSON.stringify(onFailure, null, 2)
);
const updateIgnoreFailure = (e: EuiSwitchEvent) => {
const checked = e.target.checked;
handleAdvancedConfigUpdate({
ignoreFailure: checked,
...(checked === true ? { takeActionOnFailure: false, onFailure: undefined } : {}),
});
};
const updateOnFailure = () => {
handleAdvancedConfigUpdate({ onFailure: JSON.parse(onFailureString) });
setEditOnFailure(false);
};
const handleOnFailureChange = (json: string) => {
setOnFailureString(json);
const valid = isValidJson(json);
setIsOnFailureValid(valid);
};
const handleTakeActionOnFailureChange = (checked: boolean) => {
handleAdvancedConfigUpdate({
takeActionOnFailure: checked,
onFailure: checked === false ? undefined : getDefaultOnFailureConfiguration(),
});
if (checked === false) {
setEditOnFailure(false);
setIsOnFailureValid(true);
}
};
const resetOnFailure = () => {
setOnFailureString(JSON.stringify(getDefaultOnFailureConfiguration(), null, 2));
setIsOnFailureValid(true);
};
return (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem grow={3}>
<EuiTitle size="s">
<h4>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.onFailureTitle',
{ defaultMessage: 'Ingesting problematic documents' }
)}
</h4>
</EuiTitle>
<EuiSpacer size="m" />
<EuiText color="subdued" size="s">
<p>
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.handleFailuresExplanation"
defaultMessage="If the model fails to produce a prediction, the document will be ingested without the prediction."
/>
</p>
<p>
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.handleFailuresDescription"
defaultMessage="By default, pipeline processing stops on failure. To run the pipeline's remaining processors despite the failure, {ignoreFailure} is set to true. {inferenceDocsLink}."
values={{
ignoreFailure: <EuiCode>{'ignore_failure'}</EuiCode>,
inferenceDocsLink: (
<EuiLink external target="_blank" href={links.ingest.pipelineFailure}>
Learn more.
</EuiLink>
),
}}
/>
</p>
<p>
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.onFailureDescription"
defaultMessage="The {onFailure} configuration shown will be used as a default. It is used to specify a list of processors to run immediately after the inference processor failure and provides information on why the failure occurred. {onFailureDocsLink}"
values={{
onFailure: <EuiCode>{'on_failure'}</EuiCode>,
onFailureDocsLink: (
<EuiLink external target="_blank" href={links.ingest.pipelineFailure}>
Learn more.
</EuiLink>
),
}}
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={7}>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiFormRow fullWidth>
<EuiSwitch
label={
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.ignoreFailureLabel"
defaultMessage="Ignore failure and run the pipeline's remaining processors"
/>
}
checked={ignoreFailure}
onChange={updateIgnoreFailure}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{ignoreFailure === false ? (
<EuiFormRow>
<EuiSwitch
label={
<EuiFlexItem>
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.noActionOnFailureLabel"
defaultMessage="Take action on failure"
/>
</EuiFlexItem>
}
checked={takeActionOnFailure}
onChange={(e: EuiSwitchEvent) =>
handleTakeActionOnFailureChange(e.target.checked)
}
/>
</EuiFormRow>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{takeActionOnFailure === true && ignoreFailure === false ? (
<EuiFormRow
fullWidth
label={
<EuiText size="s">
<strong>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.onFailureHeadingLabel',
{ defaultMessage: 'Actions to take on failure' }
)}
</strong>
</EuiText>
}
labelAppend={
<EuiFlexGroup gutterSize="xs" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="pencil"
size="xs"
onClick={() => {
setEditOnFailure(!editOnFailure);
}}
>
{editOnFailure ? CANCEL_EDIT_MESSAGE : EDIT_MESSAGE}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{editOnFailure ? (
<SaveChangesButton
onClick={updateOnFailure}
disabled={isOnFailureValid === false}
/>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{editOnFailure ? (
<EuiButtonEmpty size="xs" onClick={resetOnFailure}>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.resetOnFailureButton',
{ defaultMessage: 'Reset' }
)}
</EuiButtonEmpty>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
}
helpText={
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.onFailureHelpText"
defaultMessage="In case of failure, this configuration stores the document, provides the timestamp at which ingest failed, and the context for the failure."
/>
}
>
<>
{!editOnFailure ? (
<EuiCodeBlock
isCopyable={true}
overflowHeight={350}
css={{ height: '350px' }}
>
{JSON.stringify(onFailure, null, 2)}
</EuiCodeBlock>
) : null}
{editOnFailure ? (
<CodeEditor
height={300}
languageId="json"
options={{
automaticLayout: true,
lineNumbers: 'off',
tabSize: 2,
}}
value={onFailureString}
onChange={handleOnFailureChange}
/>
) : null}
</>
</EuiFormRow>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);

View file

@ -0,0 +1,223 @@
/*
* 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, { FC, memo } from 'react';
import {
EuiCode,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiLink,
EuiSpacer,
EuiTitle,
EuiText,
EuiTextArea,
EuiPanel,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useMlKibana } from '../../../contexts/kibana';
import type { MlInferenceState } from '../types';
interface Props {
handlePipelineConfigUpdate: (configUpdate: Partial<MlInferenceState>) => void;
modelId: string;
pipelineNameError: string | undefined;
pipelineName: string;
pipelineDescription: string;
targetField: string;
targetFieldError: string | undefined;
}
export const PipelineDetails: FC<Props> = memo(
({
handlePipelineConfigUpdate,
modelId,
pipelineName,
pipelineNameError,
pipelineDescription,
targetField,
targetFieldError,
}) => {
const {
services: {
docLinks: { links },
},
} = useMlKibana();
const handleConfigChange = (value: string, type: string) => {
handlePipelineConfigUpdate({ [type]: value });
};
return (
<EuiFlexGroup>
<EuiFlexItem grow={3}>
<EuiTitle size="s">
<h4>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.configure.title',
{ defaultMessage: 'Create a pipeline' }
)}
</h4>
</EuiTitle>
<EuiSpacer size="m" />
<EuiText color="subdued" size="s">
<p>
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.configure.description"
defaultMessage="Build a {pipeline} to use the trained data frame analytics model - {modelId} - for inference."
values={{
modelId: <EuiCode>{modelId}</EuiCode>,
pipeline: (
<EuiLink external target="_blank" href={links.ingest.pipelines}>
pipeline
</EuiLink>
),
}}
/>
</p>
<p>
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.configure.descriptionUsePipelines"
defaultMessage="Use {pipelineSimulateLink} or {reindexLink} to pass data into this pipeline. Predictions are stored in the Target field."
values={{
reindexLink: (
<EuiLink
external
target="_blank"
href={links.upgradeAssistant.reindexWithPipeline}
>
_reindex API
</EuiLink>
),
pipelineSimulateLink: (
<EuiLink external target="_blank" href={links.apis.simulatePipeline}>
pipeline/_simulate
</EuiLink>
),
}}
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={7}>
<EuiPanel hasBorder={false} hasShadow={false}>
{/* NAME */}
<EuiForm component="form">
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.configure.nameLabel',
{
defaultMessage: 'Name',
}
)}
helpText={
!pipelineNameError && (
<EuiText size="xs">
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.configure.name.helpText',
{
defaultMessage:
'Pipeline names are unique within a deployment and can only contain letters, numbers, underscores, and hyphens.',
}
)}
</EuiText>
)
}
error={pipelineNameError}
isInvalid={pipelineNameError !== undefined}
>
<EuiFieldText
fullWidth
placeholder={i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.configure.namePlaceholder',
{
defaultMessage: 'Enter a unique name for this pipeline',
}
)}
value={pipelineName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleConfigChange(e.target.value, 'pipelineName')
}
/>
</EuiFormRow>
{/* DESCRIPTION */}
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.configure.descriptionLabel',
{
defaultMessage: 'Description',
}
)}
helpText={
<EuiText size="xs">
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.configure.description.helpText',
{
defaultMessage: 'A description of what this pipeline does.',
}
)}
</EuiText>
}
>
<EuiTextArea
compressed
fullWidth
placeholder={i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.configure.descriptionPlaceholder',
{
defaultMessage: 'Add a description of what this pipeline does.',
}
)}
value={pipelineDescription}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
handleConfigChange(e.target.value, 'pipelineDescription')
}
/>
</EuiFormRow>
{/* TARGET FIELD */}
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.configure.targetFieldLabel',
{
defaultMessage: 'Target field (optional)',
}
)}
helpText={
!targetFieldError && (
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.configure.targetFieldHelpText"
defaultMessage="Field used to contain inference processor results. Defaults to {targetField}."
values={{ targetField: <EuiCode>{'ml.inference.<processor_tag>'}</EuiCode> }}
/>
)
}
error={targetFieldError}
isInvalid={targetFieldError !== undefined}
>
<EuiFieldText
fullWidth
value={targetField}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleConfigChange(e.target.value, 'targetField')
}
/>
</EuiFormRow>
</EuiForm>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);

View file

@ -0,0 +1,391 @@
/*
* 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, { FC, useState, memo } from 'react';
import {
EuiButtonEmpty,
EuiCode,
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiLink,
EuiSpacer,
EuiText,
EuiTitle,
EuiPopover,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { CodeEditor } from '@kbn/kibana-react-plugin/public';
import { ModelItem } from '../../../model_management/models_list';
import {
EDIT_MESSAGE,
CANCEL_EDIT_MESSAGE,
CREATE_FIELD_MAPPING_MESSAGE,
CLEAR_BUTTON_LABEL,
} from '../constants';
import { validateInferenceConfig } from '../validation';
import { isValidJson } from '../../../../../common/util/validation_utils';
import { SaveChangesButton } from './save_changes_button';
import { useMlKibana } from '../../../contexts/kibana';
import type { MlInferenceState, InferenceModelTypes } from '../types';
import { AdditionalAdvancedSettings } from './additional_advanced_settings';
import { validateFieldMap } from '../validation';
function getDefaultFieldMapString() {
return JSON.stringify(
{
field_map: {
incoming_field: 'field_the_model_expects',
},
},
null,
2
);
}
interface Props {
condition?: string;
fieldMap: MlInferenceState['fieldMap'];
handleAdvancedConfigUpdate: (configUpdate: Partial<MlInferenceState>) => void;
inferenceConfig: ModelItem['inference_config'];
modelInferenceConfig: ModelItem['inference_config'];
modelInputFields: ModelItem['input'];
modelType?: InferenceModelTypes;
setHasUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
tag?: string;
}
export const ProcessorConfiguration: FC<Props> = memo(
({
condition,
fieldMap,
handleAdvancedConfigUpdate,
inferenceConfig,
modelInputFields,
modelInferenceConfig,
modelType,
setHasUnsavedChanges,
tag,
}) => {
const {
services: {
docLinks: { links },
},
} = useMlKibana();
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const [editInferenceConfig, setEditInferenceConfig] = useState<boolean>(false);
const [editFieldMapping, setEditFieldMapping] = useState<boolean>(false);
const [inferenceConfigString, setInferenceConfigString] = useState<string>(
JSON.stringify(inferenceConfig, null, 2)
);
const [inferenceConfigError, setInferenceConfigError] = useState<string | undefined>();
const [fieldMapError, setFieldMapError] = useState<string | undefined>();
const [fieldMappingString, setFieldMappingString] = useState<string>(
fieldMap ? JSON.stringify(fieldMap, null, 2) : getDefaultFieldMapString()
);
const [isInferenceConfigValid, setIsInferenceConfigValid] = useState<boolean>(true);
const [isFieldMapValid, setIsFieldMapValid] = useState<boolean>(true);
const handleInferenceConfigChange = (json: string) => {
setInferenceConfigString(json);
const valid = isValidJson(json);
setIsInferenceConfigValid(valid);
};
const updateInferenceConfig = () => {
const invalidInferenceConfigMessage = validateInferenceConfig(
JSON.parse(inferenceConfigString),
modelType
);
if (invalidInferenceConfigMessage === undefined) {
handleAdvancedConfigUpdate({ inferenceConfig: JSON.parse(inferenceConfigString) });
setHasUnsavedChanges(false);
setEditInferenceConfig(false);
setInferenceConfigError(undefined);
} else {
setHasUnsavedChanges(true);
setIsInferenceConfigValid(false);
setInferenceConfigError(invalidInferenceConfigMessage);
}
};
const resetInferenceConfig = () => {
setInferenceConfigString(JSON.stringify(modelInferenceConfig, null, 2));
setIsInferenceConfigValid(true);
setInferenceConfigError(undefined);
};
const clearFieldMap = () => {
setFieldMappingString('{}');
setIsFieldMapValid(true);
setFieldMapError(undefined);
};
const handleFieldMapChange = (json: string) => {
setFieldMappingString(json);
const valid = isValidJson(json);
setIsFieldMapValid(valid);
};
const updateFieldMap = () => {
const invalidFieldMapMessage = validateFieldMap(
modelInputFields.field_names ?? [],
JSON.parse(fieldMappingString)
);
if (invalidFieldMapMessage === undefined) {
handleAdvancedConfigUpdate({ fieldMap: JSON.parse(fieldMappingString) });
setHasUnsavedChanges(false);
setEditFieldMapping(false);
setFieldMapError(undefined);
} else {
setHasUnsavedChanges(true);
setIsFieldMapValid(false);
setFieldMapError(invalidFieldMapMessage);
}
};
return (
<EuiFlexGroup direction="column">
{/* INFERENCE CONFIG */}
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem grow={3}>
<EuiTitle size="s">
<h4>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.inferenceConfigurationTitle',
{ defaultMessage: 'Inference configuration' }
)}
</h4>
</EuiTitle>
<EuiSpacer size="m" />
<EuiText color="subdued" size="s">
<p>
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.description"
defaultMessage="The inference type and its options. Unless otherwise specified, the default configuration options are used. {inferenceDocsLink}."
values={{
inferenceDocsLink: (
<EuiLink external target="_blank" href={links.ingest.inference}>
Learn more.
</EuiLink>
),
}}
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={7}>
<EuiFormRow
fullWidth
labelAppend={
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="pencil"
size="xs"
onClick={() => {
if (!editInferenceConfig === false) {
setInferenceConfigError(undefined);
setIsInferenceConfigValid(true);
}
setEditInferenceConfig(!editInferenceConfig);
}}
>
{editInferenceConfig ? CANCEL_EDIT_MESSAGE : EDIT_MESSAGE}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{editInferenceConfig ? (
<SaveChangesButton
onClick={updateInferenceConfig}
disabled={isInferenceConfigValid === false}
/>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{editInferenceConfig ? (
<EuiButtonEmpty size="xs" onClick={resetInferenceConfig}>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.resetInferenceConfigButton',
{ defaultMessage: 'Reset' }
)}
</EuiButtonEmpty>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
}
error={inferenceConfigError ?? inferenceConfigError}
isInvalid={inferenceConfigError !== undefined || inferenceConfigError !== undefined}
>
{editInferenceConfig ? (
<CodeEditor
height={300}
languageId="json"
options={{
automaticLayout: true,
lineNumbers: 'off',
tabSize: 2,
}}
value={inferenceConfigString}
onChange={handleInferenceConfigChange}
/>
) : (
<EuiCodeBlock isCopyable={true}>
{JSON.stringify(inferenceConfig, null, 2)}
</EuiCodeBlock>
)}
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{/* FIELD MAP */}
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem grow={3}>
<EuiTitle size="s">
<h4>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.fieldMapTitle',
{ defaultMessage: 'Fields' }
)}
</h4>
</EuiTitle>
<EuiSpacer size="m" />
<EuiText color="subdued" size="s">
<p>
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.fieldMapDescriptionTwo"
defaultMessage="The model expects certain input fields. {fieldsList}"
values={{
fieldsList: (
<EuiPopover
button={
<EuiLink onClick={() => setIsPopoverOpen(!isPopoverOpen)}>
You can review them here.
</EuiLink>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
anchorPosition="downLeft"
>
<EuiCodeBlock isCopyable={true}>
{JSON.stringify(modelInputFields, null, 2)}
</EuiCodeBlock>
</EuiPopover>
),
}}
/>
</p>
<p>
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.fieldMapExtendedDescription"
defaultMessage="If the fields for the incoming data differ, a {fieldMap} must be created to map the input document field name to the name of the field that the model expects. It must be in JSON format. {inferenceDocsLink}"
values={{
fieldMap: <EuiCode>{'field_map'}</EuiCode>,
inferenceDocsLink: (
<EuiLink external target="_blank" href={links.ingest.inference}>
Learn more.
</EuiLink>
),
}}
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={7}>
<EuiFormRow
fullWidth
labelAppend={
<EuiFlexGroup gutterSize="xs" justifyContent="flexStart">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="pencil"
size="xs"
onClick={() => {
const editingState = !editFieldMapping;
if (editingState === false) {
setFieldMapError(undefined);
setIsFieldMapValid(true);
setHasUnsavedChanges(false);
}
setEditFieldMapping(editingState);
}}
>
{editFieldMapping
? CANCEL_EDIT_MESSAGE
: fieldMap !== undefined
? EDIT_MESSAGE
: CREATE_FIELD_MAPPING_MESSAGE}
</EuiButtonEmpty>
</EuiFlexItem>
{editFieldMapping ? (
<EuiFlexItem grow={false}>
<SaveChangesButton
onClick={updateFieldMap}
disabled={isFieldMapValid === false}
/>
</EuiFlexItem>
) : null}
{editFieldMapping ? (
<EuiFlexItem grow={false}>
<EuiButtonEmpty size="xs" onClick={clearFieldMap}>
{CLEAR_BUTTON_LABEL}
</EuiButtonEmpty>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
}
error={fieldMapError}
isInvalid={fieldMapError !== undefined}
>
<>
{!editFieldMapping ? (
<EuiCodeBlock isCopyable={true} overflowHeight={350}>
{JSON.stringify(fieldMap ?? {}, null, 2)}
</EuiCodeBlock>
) : null}
{editFieldMapping ? (
<>
<EuiSpacer size="s" />
<CodeEditor
height={300}
languageId="json"
options={{
automaticLayout: true,
lineNumbers: 'off',
tabSize: 2,
}}
value={fieldMappingString}
onChange={handleFieldMapChange}
/>
</>
) : null}
</>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{/* ADDITIONAL ADVANCED SETTINGS */}
<EuiFlexItem>
<AdditionalAdvancedSettings
handleAdvancedConfigUpdate={handleAdvancedConfigUpdate}
condition={condition}
tag={tag}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);

View file

@ -0,0 +1,214 @@
/*
* 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, { FC, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiAccordion,
EuiCallOut,
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiSpacer,
EuiTitle,
EuiText,
htmlIdGenerator,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { IngestPipeline } from '@elastic/elasticsearch/lib/api/types';
import { useMlKibana } from '../../../contexts/kibana';
const MANAGEMENT_APP_ID = 'management';
interface Props {
inferencePipeline: IngestPipeline;
modelType?: string;
pipelineName: string;
pipelineCreated: boolean;
pipelineError?: string;
}
export const ReviewAndCreatePipeline: FC<Props> = ({
inferencePipeline,
modelType,
pipelineName,
pipelineCreated,
pipelineError,
}) => {
const {
services: {
application,
docLinks: { links },
},
} = useMlKibana();
const inferenceProcessorLink =
modelType === 'regression'
? links.ingest.inferenceRegression
: links.ingest.inferenceClassification;
const accordionId = useMemo(() => htmlIdGenerator()(), []);
const configCodeBlock = useMemo(
() => (
<EuiCodeBlock language="json" isCopyable overflowHeight="400px">
{JSON.stringify(inferencePipeline ?? {}, null, 2)}
</EuiCodeBlock>
),
[inferencePipeline]
);
return (
<>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={3}>
{pipelineCreated === false ? (
<EuiTitle size="s">
<h4>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.review.title',
{
defaultMessage: "Review the pipeline configuration for '{pipelineName}'",
values: { pipelineName },
}
)}
</h4>
</EuiTitle>
) : null}
<>
<EuiSpacer size="s" />
{pipelineCreated === true && pipelineError === undefined ? (
<EuiCallOut
title={i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.create.successMessage',
{
defaultMessage: "'{pipelineName}' has been created successfully.",
values: { pipelineName },
}
)}
color="success"
iconType="check"
>
<p>
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.create.reIndexingMessage"
defaultMessage="You can use this pipeline to infer against new data or infer against existing data by {reindexLink} with the pipeline."
values={{
reindexLink: (
<EuiLink
href={links.upgradeAssistant.reindexWithPipeline}
target="_blank"
external
>
{'reindexing'}
</EuiLink>
),
}}
/>
{application.capabilities.management?.ingest?.ingest_pipelines ? (
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.create.ingestPipelinesManagementMessage"
defaultMessage=" Navigate to {pipelineManagementLink} to view and manage pipelines."
values={{
pipelineManagementLink: (
<EuiLink
onClick={async () => {
await application.navigateToApp(MANAGEMENT_APP_ID, {
path: `/ingest/ingest_pipelines/?pipeline=${pipelineName}`,
openInNewTab: true,
});
}}
target="_blank"
external
>
{'Ingest Pipelines'}
</EuiLink>
),
}}
/>
) : null}
</p>
</EuiCallOut>
) : null}
{pipelineError !== undefined ? (
<EuiCallOut
title={i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.create.failureMessage',
{
defaultMessage: "Unable to create '{pipelineName}'.",
values: { pipelineName },
}
)}
color="danger"
iconType="error"
>
<p>{pipelineError}</p>
<p>
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.create.docLinkInErrorMessage"
defaultMessage="Learn more about {ingestPipelineConfigLink} and {inferencePipelineConfigLink} configuration."
values={{
ingestPipelineConfigLink: (
<EuiLink href={links.ingest.pipelines} external target={'_blank'}>
{'ingest pipeline'}
</EuiLink>
),
inferencePipelineConfigLink: (
<EuiLink
href={modelType ? inferenceProcessorLink : links.ingest.inference}
external
target={'_blank'}
>
{'inference processor'}
</EuiLink>
),
}}
/>
</p>
</EuiCallOut>
) : null}
</>
</EuiFlexItem>
<EuiFlexItem grow={7}>
<EuiText color="subdued" size="s">
<p>
{!pipelineCreated ? (
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.review.description"
defaultMessage="This pipeline will be created with the configuration below."
/>
) : null}
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow>
{pipelineCreated ? (
<>
<EuiSpacer size="m" />
<EuiAccordion
id={accordionId}
buttonContent={
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.review.viewConfig"
defaultMessage="View configuration"
/>
}
>
{configCodeBlock}
</EuiAccordion>
</>
) : (
[configCodeBlock]
)}
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
interface SaveChangesButtonProps {
onClick: () => void;
disabled: boolean;
}
export const SaveChangesButton: FC<SaveChangesButtonProps> = ({ onClick, disabled }) => (
<EuiButtonEmpty size="xs" onClick={onClick} disabled={disabled}>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.saveChangesButton',
{ defaultMessage: 'Save changes' }
)}
</EuiButtonEmpty>
);

View file

@ -0,0 +1,279 @@
/*
* 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, { FC, memo, useEffect, useCallback, useState } from 'react';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
EuiButton,
EuiButtonEmpty,
EuiCode,
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiResizableContainer,
EuiSpacer,
EuiTitle,
EuiText,
useIsWithinMaxBreakpoint,
EuiPanel,
} from '@elastic/eui';
import { IngestSimulateDocument } from '@elastic/elasticsearch/lib/api/types';
import { extractErrorProperties } from '@kbn/ml-error-utils';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { CodeEditor } from '@kbn/kibana-react-plugin/public';
import { useMlApiContext, useMlKibana } from '../../../contexts/kibana';
import { getPipelineConfig } from '../get_pipeline_config';
import { isValidJson } from '../../../../../common/util/validation_utils';
import type { MlInferenceState } from '../types';
interface Props {
sourceIndex?: string;
state: MlInferenceState;
}
export const TestPipeline: FC<Props> = memo(({ state, sourceIndex }) => {
const [simulatePipelineResult, setSimulatePipelineResult] = useState<
undefined | estypes.IngestSimulateResponse
>();
const [simulatePipelineError, setSimulatePipelineError] = useState<undefined | string>();
const [sampleDocsString, setSampleDocsString] = useState<string>('');
const [isValid, setIsValid] = useState<boolean>(true);
const {
esSearch,
trainedModels: { trainedModelPipelineSimulate },
} = useMlApiContext();
const {
notifications: { toasts },
} = useMlKibana();
const isSmallerViewport = useIsWithinMaxBreakpoint('s');
const simulatePipeline = async () => {
try {
const pipelineConfig = getPipelineConfig(state);
const result = await trainedModelPipelineSimulate(
pipelineConfig,
JSON.parse(sampleDocsString) as IngestSimulateDocument[]
);
setSimulatePipelineResult(result);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
const errorProperties = extractErrorProperties(error);
setSimulatePipelineError(error);
toasts.danger({
title: i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.test.errorSimulatingPipeline',
{
defaultMessage: 'Unable to simulate pipeline.',
}
),
body: errorProperties.message,
toastLifeTimeMs: 5000,
});
}
};
const clearResults = () => {
setSimulatePipelineResult(undefined);
setSimulatePipelineError(undefined);
};
const onChange = (json: string) => {
setSampleDocsString(json);
const valid = isValidJson(json);
setIsValid(valid);
};
const getSampleDocs = useCallback(async () => {
let records: IngestSimulateDocument[] = [];
let resp;
try {
resp = await esSearch({
index: sourceIndex,
body: {
size: 1,
},
});
if (resp && resp.hits.total.value > 0) {
records = resp.hits.hits;
}
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
}
setSampleDocsString(JSON.stringify(records, null, 2));
setIsValid(true);
}, [sourceIndex, esSearch]);
useEffect(
function fetchSampleDocsFromSource() {
if (sourceIndex) {
getSampleDocs();
}
},
[sourceIndex, getSampleDocs]
);
return (
<>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem>
<EuiTitle size="s">
<h4>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.test.title',
{ defaultMessage: 'Test the pipeline results' }
)}
</h4>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued" size="s">
<p>
<strong>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.test.optionalCallout',
{ defaultMessage: 'This is an optional step.' }
)}
</strong>
&nbsp;
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.test.description"
defaultMessage="Run a simulation of the pipeline to confirm it produces the anticipated results."
/>{' '}
{state.targetField && (
<FormattedMessage
id="xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.test.targetFieldHint"
defaultMessage="Check for the target field {targetField} for the prediction in the Result tab."
values={{ targetField: <EuiCode>{state.targetField}</EuiCode> }}
/>
)}
</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiPanel hasBorder={false} hasShadow={false}>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<div>
<EuiButton
onClick={simulatePipeline}
disabled={sampleDocsString === '' || !isValid}
>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.test.runButton',
{ defaultMessage: 'Simulate pipeline' }
)}
</EuiButton>
</div>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
onClick={clearResults}
disabled={simulatePipelineResult === undefined}
>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.test.clearResultsButton',
{ defaultMessage: 'Clear results' }
)}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
onClick={getSampleDocs}
disabled={sampleDocsString === ''}
>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.test.resetSampleDocsButton',
{ defaultMessage: 'Reset sample docs' }
)}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem>
<EuiText>
<h5>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.test.subtitle.documents',
{ defaultMessage: 'Raw document' }
)}
</h5>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
<h5>
{i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.test.subtitle.result',
{ defaultMessage: 'Result' }
)}
</h5>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiResizableContainer
direction={isSmallerViewport ? 'vertical' : 'horizontal'}
css={css`
min-height: calc(${euiThemeVars.euiSizeXL} * 10);
`}
>
{(EuiResizablePanel, EuiResizableButton) => (
<>
<EuiResizablePanel grow hasBorder initialSize={50} paddingSize="xs">
<CodeEditor
languageId="json"
options={{
automaticLayout: true,
lineNumbers: 'off',
tabSize: 2,
}}
value={sampleDocsString}
onChange={onChange}
/>
</EuiResizablePanel>
<EuiResizableButton />
<EuiResizablePanel grow={false} hasBorder initialSize={50} paddingSize="xs">
<EuiCodeBlock language="json" isCopyable className="reviewCodeBlock">
{simulatePipelineError
? JSON.stringify(simulatePipelineError, null, 2)
: simulatePipelineResult
? JSON.stringify(simulatePipelineResult, null, 2)
: '{}'}
</EuiCodeBlock>
</EuiResizablePanel>
</>
)}
</EuiResizableContainer>
</EuiFlexItem>
<EuiSpacer />
</EuiFlexGroup>
</EuiPanel>
</>
);
});

View file

@ -0,0 +1,61 @@
/*
* 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';
export const ADD_INFERENCE_PIPELINE_STEPS = {
DETAILS: 'Details',
CONFIGURE_PROCESSOR: 'Configure processor',
ON_FAILURE: 'Failure handling',
TEST: 'Test',
CREATE: 'create',
} as const;
export const CANCEL_BUTTON_LABEL = i18n.translate(
'xpack.ml.trainedModels.actions.cancelButtonLabel',
{ defaultMessage: 'Cancel' }
);
export const CLEAR_BUTTON_LABEL = i18n.translate(
'xpack.ml.trainedModels.actions.clearButtonLabel',
{ defaultMessage: 'Clear' }
);
export const CLOSE_BUTTON_LABEL = i18n.translate(
'xpack.ml.trainedModels.actions.closeButtonLabel',
{ defaultMessage: 'Close' }
);
export const BACK_BUTTON_LABEL = i18n.translate('xpack.ml.trainedModels.actions.backButtonLabel', {
defaultMessage: 'Back',
});
export const CONTINUE_BUTTON_LABEL = i18n.translate(
'xpack.ml.trainedModels.actions.continueButtonLabel',
{ defaultMessage: 'Continue' }
);
export const EDIT_MESSAGE = i18n.translate(
'xpack.ml.trainedModels.actions.create.advancedDetails.editButtonText',
{
defaultMessage: 'Edit',
}
);
export const CREATE_FIELD_MAPPING_MESSAGE = i18n.translate(
'xpack.ml.trainedModels.actions.create.advancedDetails.createFieldMapText',
{
defaultMessage: 'Create field map',
}
);
export const CANCEL_EDIT_MESSAGE = i18n.translate(
'xpack.ml.trainedModels.actions.create.advancedDetails.cancelEditButtonText',
{
defaultMessage: 'Cancel',
}
);

View file

@ -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 type { MlInferenceState } from './types';
export function getPipelineConfig(state: MlInferenceState) {
const {
condition,
fieldMap,
ignoreFailure,
inferenceConfig,
modelId,
onFailure,
pipelineDescription,
tag,
targetField,
} = state;
return {
description: pipelineDescription,
processors: [
{
inference: {
model_id: modelId,
ignore_failure: ignoreFailure,
...(targetField && targetField !== '' ? { target_field: targetField } : {}),
...(fieldMap && Object.keys(fieldMap).length > 0 ? { field_map: fieldMap } : {}),
...(inferenceConfig && Object.keys(inferenceConfig).length > 0
? { inference_config: inferenceConfig }
: {}),
...(condition && condition !== '' ? { if: condition } : {}),
...(tag && tag !== '' ? { tag } : {}),
...(onFailure && Object.keys(onFailure).length > 0 ? { on_failure: onFailure } : {}),
},
},
],
};
}

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 { AddInferencePipelineSteps } from './types';
import { ADD_INFERENCE_PIPELINE_STEPS } from './constants';
export function getSteps(
step: AddInferencePipelineSteps,
isConfigureStepValid: boolean,
isPipelineDataValid: boolean
) {
let nextStep: AddInferencePipelineSteps | undefined;
let previousStep: AddInferencePipelineSteps | undefined;
let isContinueButtonEnabled = false;
switch (step) {
case ADD_INFERENCE_PIPELINE_STEPS.DETAILS:
nextStep = ADD_INFERENCE_PIPELINE_STEPS.CONFIGURE_PROCESSOR;
isContinueButtonEnabled = isConfigureStepValid;
break;
case ADD_INFERENCE_PIPELINE_STEPS.CONFIGURE_PROCESSOR:
nextStep = ADD_INFERENCE_PIPELINE_STEPS.ON_FAILURE;
previousStep = ADD_INFERENCE_PIPELINE_STEPS.DETAILS;
isContinueButtonEnabled = isPipelineDataValid;
break;
case ADD_INFERENCE_PIPELINE_STEPS.ON_FAILURE:
nextStep = ADD_INFERENCE_PIPELINE_STEPS.TEST;
previousStep = ADD_INFERENCE_PIPELINE_STEPS.CONFIGURE_PROCESSOR;
isContinueButtonEnabled = isPipelineDataValid;
break;
case ADD_INFERENCE_PIPELINE_STEPS.TEST:
nextStep = ADD_INFERENCE_PIPELINE_STEPS.CREATE;
previousStep = ADD_INFERENCE_PIPELINE_STEPS.ON_FAILURE;
isContinueButtonEnabled = true;
break;
case ADD_INFERENCE_PIPELINE_STEPS.CREATE:
previousStep = ADD_INFERENCE_PIPELINE_STEPS.TEST;
isContinueButtonEnabled = true;
break;
}
return { nextStep, previousStep, isContinueButtonEnabled };
}

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 { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { useMlApiContext, useMlKibana } from '../../../contexts/kibana';
export const useFetchPipelines = () => {
const [pipelineNames, setPipelineNames] = useState<string[]>([]);
const {
notifications: { toasts },
} = useMlKibana();
const {
trainedModels: { getAllIngestPipelines },
} = useMlApiContext();
useEffect(() => {
async function fetchPipelines() {
let names: string[] = [];
try {
const results = await getAllIngestPipelines();
names = Object.keys(results);
setPipelineNames(names);
} catch (e) {
toasts.danger({
title: i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.fetchIngestPipelinesError',
{
defaultMessage: 'Unable to fetch ingest pipelines.',
}
),
body: e.message,
toastLifeTimeMs: 5000,
});
}
}
fetchPipelines();
}, [getAllIngestPipelines, toasts]);
return pipelineNames;
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { AddInferencePipelineFlyout } from './add_inference_pipeline_flyout';

View file

@ -0,0 +1,83 @@
/*
* 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 { getAnalysisType } from '@kbn/ml-data-frame-analytics-utils';
import type { MlInferenceState } from './types';
import { ModelItem } from '../../model_management/models_list';
export const getModelType = (model: ModelItem): string | undefined => {
const analysisConfig = model.metadata?.analytics_config?.analysis;
return analysisConfig !== undefined ? getAnalysisType(analysisConfig) : undefined;
};
export const getDefaultOnFailureConfiguration = (): MlInferenceState['onFailure'] => [
{
set: {
description: "Index document to 'failed-<index>'",
field: '_index',
value: 'failed-{{{ _index }}}',
},
},
{
set: {
field: 'event.timestamp',
value: '{{{ _ingest.timestamp }}}',
},
},
{
set: {
field: 'event.failure.message',
value: '{{{ _ingest.on_failure_message }}}',
},
},
{
set: {
field: 'event.failure.processor_type',
value: '{{{ _ingest.on_failure_processor_type }}}',
},
},
{
set: {
field: 'event.failure.processor_tag',
value: '{{{ _ingest.on_failure_processor_tag }}}',
},
},
{
set: {
field: 'event.failure.pipeline',
value: '{{{ _ingest.on_failure_pipeline }}}',
},
},
];
export const getInitialState = (model: ModelItem): MlInferenceState => {
const modelType = getModelType(model);
let targetField;
if (modelType !== undefined) {
targetField = model.inference_config
? `ml.inference.${model.inference_config[modelType].results_field}`
: undefined;
}
return {
condition: undefined,
creatingPipeline: false,
error: false,
fieldMap: undefined,
ignoreFailure: false,
inferenceConfig: model.inference_config,
modelId: model.model_id,
onFailure: getDefaultOnFailureConfiguration(),
pipelineDescription: `Uses the pre-trained data frame analytics model ${model.model_id} to infer against the data that is being ingested in the pipeline`,
pipelineName: `ml-inference-${model.model_id}`,
pipelineCreated: false,
tag: undefined,
takeActionOnFailure: true,
targetField: targetField ?? '',
};
};

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IngestInferenceProcessor } from '@elastic/elasticsearch/lib/api/types';
import { ADD_INFERENCE_PIPELINE_STEPS } from './constants';
export type AddInferencePipelineSteps =
typeof ADD_INFERENCE_PIPELINE_STEPS[keyof typeof ADD_INFERENCE_PIPELINE_STEPS];
export interface MlInferenceState {
condition?: string;
creatingPipeline: boolean;
error: boolean;
fieldMap?: IngestInferenceProcessor['field_map'];
fieldMapError?: string;
ignoreFailure: boolean;
inferenceConfig: IngestInferenceProcessor['inference_config'];
inferenceConfigError?: string;
modelId: string;
onFailure?: IngestInferenceProcessor['on_failure'];
pipelineName: string;
pipelineNameError?: string;
pipelineDescription: string;
pipelineCreated: boolean;
pipelineError?: string;
tag?: string;
targetField: string;
targetFieldError?: string;
takeActionOnFailure: boolean;
}
export interface AddInferencePipelineFormErrors {
targetField?: string;
fieldMap?: string;
inferenceConfig?: string;
pipelineName?: string;
}
export type InferenceModelTypes = 'regression' | 'classification';
export interface AdditionalSettings {
condition?: string;
tag?: string;
}

View file

@ -0,0 +1,118 @@
/*
* 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 { IngestInferenceProcessor } from '@elastic/elasticsearch/lib/api/types';
import { InferenceModelTypes } from './types';
import type { AddInferencePipelineFormErrors } from './types';
const INVALID_PIPELINE_NAME_ERROR = i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.configure.invalidPipelineName',
{
defaultMessage: 'Name must only contain letters, numbers, underscores, and hyphens.',
}
);
const FIELD_REQUIRED_ERROR = i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.configure.emptyValueError',
{
defaultMessage: 'Field is required.',
}
);
const NO_EMPTY_INFERENCE_CONFIG_OBJECT = i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.configure.noEmptyInferenceConfigObjectError',
{
defaultMessage: 'Inference configuration cannot be an empty object.',
}
);
const PIPELINE_NAME_EXISTS_ERROR = i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.configure.pipelineNameExistsError',
{
defaultMessage: 'Name already used by another pipeline.',
}
);
const FIELD_MAP_REQUIRED_FIELDS_ERROR = i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.emptyValueError',
{
defaultMessage: 'Field map must include fields expected by the model.',
}
);
const INFERENCE_CONFIG_MODEL_TYPE_ERROR = i18n.translate(
'xpack.ml.trainedModels.content.indices.pipelines.addInferencePipelineModal.steps.advanced.incorrectModelTypeError',
{
defaultMessage: 'Inference configuration inference type must match model type.',
}
);
const VALID_PIPELINE_NAME_REGEX = /^[\w\-]+$/;
export const isValidPipelineName = (input: string): boolean => {
return input.length > 0 && VALID_PIPELINE_NAME_REGEX.test(input);
};
export const validateInferencePipelineConfigurationStep = (
pipelineName: string,
pipelineNames: string[]
) => {
const errors: AddInferencePipelineFormErrors = {};
if (pipelineName.trim().length === 0 || pipelineName === '') {
errors.pipelineName = FIELD_REQUIRED_ERROR;
} else if (!isValidPipelineName(pipelineName)) {
errors.pipelineName = INVALID_PIPELINE_NAME_ERROR;
}
const pipelineNameExists = pipelineNames.find((name) => name === pipelineName) !== undefined;
if (pipelineNameExists) {
errors.pipelineName = PIPELINE_NAME_EXISTS_ERROR;
}
return errors;
};
export const validateInferenceConfig = (
inferenceConfig: IngestInferenceProcessor['inference_config'],
modelType?: InferenceModelTypes
) => {
const inferenceConfigKeys = Object.keys(inferenceConfig ?? {});
let error;
// If inference config has been changed, it cannot be an empty object
if (inferenceConfig && Object.keys(inferenceConfig).length === 0) {
error = NO_EMPTY_INFERENCE_CONFIG_OBJECT;
return error;
}
// If populated, inference config must have the correct model type
if (inferenceConfig && inferenceConfigKeys.length > 0) {
if (modelType === inferenceConfigKeys[0]) {
return error;
} else {
error = INFERENCE_CONFIG_MODEL_TYPE_ERROR;
}
return error;
}
return error;
};
export const validateFieldMap = (
modelInputFields: string[],
fieldMap: IngestInferenceProcessor['field_map']
) => {
let error;
const fieldMapValues: string[] = Object.values(fieldMap?.field_map ?? {});
// If populated, field map must include at least some model input fields as values.
if (fieldMap && fieldMapValues.length > 0) {
if (fieldMapValues.some((v) => modelInputFields.includes(v))) {
return error;
} else {
error = FIELD_MAP_REQUIRED_FIELDS_ERROR;
}
}
return error;
};

View file

@ -35,6 +35,7 @@ import { ModelItem } from './models_list';
export function useModelActions({
onTestAction,
onModelsDeleteRequest,
onModelDeployRequest,
onLoading,
isLoading,
fetchModels,
@ -43,6 +44,7 @@ export function useModelActions({
isLoading: boolean;
onTestAction: (model: ModelItem) => void;
onModelsDeleteRequest: (models: ModelItem[]) => void;
onModelDeployRequest: (model: ModelItem) => void;
onLoading: (isLoading: boolean) => void;
fetchModels: () => Promise<void>;
modelAndDeploymentIds: string[];
@ -412,6 +414,54 @@ export function useModelActions({
}
},
},
{
name: (model) => {
const hasDeployments = model.state === MODEL_STATE.STARTED;
return (
<EuiToolTip
position="left"
content={
hasDeployments
? i18n.translate(
'xpack.ml.trainedModels.modelsList.deleteDisabledWithDeploymentsTooltip',
{
defaultMessage: 'Model has started deployments',
}
)
: null
}
>
<>
{i18n.translate('xpack.ml.trainedModels.modelsList.deployModelActionLabel', {
defaultMessage: 'Deploy model',
})}
</>
</EuiToolTip>
);
},
description: i18n.translate('xpack.ml.trainedModels.modelsList.deployModelActionLabel', {
defaultMessage: 'Deploy model',
}),
'data-test-subj': 'mlModelsTableRowDeployAction',
icon: 'continuityAbove',
type: 'icon',
isPrimary: false,
onClick: (model) => {
onModelDeployRequest(model);
},
available: (item) => {
const isDfaTrainedModel = item.metadata?.analytics_config !== undefined;
return (
isDfaTrainedModel &&
!isBuiltInModel(item) &&
!item.putModelConfig &&
canManageIngestPipelines
);
},
enabled: (item) => {
return item.state !== MODEL_STATE.STARTED;
},
},
{
name: (model) => {
const hasDeployments = model.state === MODEL_STATE.STARTED;
@ -492,6 +542,7 @@ export function useModelActions({
displayErrorToast,
getUserConfirmation,
onModelsDeleteRequest,
onModelDeployRequest,
canDeleteTrainedModels,
isBuiltInModel,
onTestAction,

View file

@ -62,6 +62,7 @@ import { useFieldFormatter } from '../contexts/kibana/use_field_formatter';
import { useRefresh } from '../routing/use_refresh';
import { SavedObjectsWarning } from '../components/saved_objects_warning';
import { TestTrainedModelFlyout } from './test_models';
import { AddInferencePipelineFlyout } from '../components/ml_inference';
type Stats = Omit<TrainedModelStat, 'model_id' | 'deployment_stats'>;
@ -134,6 +135,7 @@ export const ModelsList: FC<Props> = ({
const [items, setItems] = useState<ModelItem[]>([]);
const [selectedModels, setSelectedModels] = useState<ModelItem[]>([]);
const [modelsToDelete, setModelsToDelete] = useState<ModelItem[]>([]);
const [modelToDeploy, setModelToDeploy] = useState<ModelItem | undefined>();
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<Record<string, JSX.Element>>(
{}
);
@ -349,6 +351,7 @@ export const ModelsList: FC<Props> = ({
fetchModels: fetchModelsData,
onTestAction: setModelToTest,
onModelsDeleteRequest: setModelsToDelete,
onModelDeployRequest: setModelToDeploy,
onLoading: setIsLoading,
modelAndDeploymentIds,
});
@ -642,6 +645,12 @@ export const ModelsList: FC<Props> = ({
{modelToTest === null ? null : (
<TestTrainedModelFlyout model={modelToTest} onClose={setModelToTest.bind(null, null)} />
)}
{modelToDeploy !== undefined ? (
<AddInferencePipelineFlyout
onClose={setModelToDeploy.bind(null, undefined)}
model={modelToDeploy}
/>
) : null}
</>
);
};

View file

@ -6,6 +6,7 @@
*/
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { IngestPipeline } from '@elastic/elasticsearch/lib/api/types';
import { useMemo } from 'react';
import type { HttpFetchQuery } from '@kbn/core/public';
@ -58,7 +59,6 @@ export function trainedModelsApiProvider(httpService: HttpService) {
return {
/**
* Fetches configuration information for a trained inference model.
*
* @param modelId - Model ID, collection of Model IDs or Model ID pattern.
* Fetches all In case nothing is provided.
* @param params - Optional query params
@ -76,7 +76,6 @@ export function trainedModelsApiProvider(httpService: HttpService) {
/**
* Fetches usage information for trained inference models.
*
* @param modelId - Model ID, collection of Model IDs or Model ID pattern.
* Fetches all In case nothing is provided.
* @param params - Optional query params
@ -93,7 +92,6 @@ export function trainedModelsApiProvider(httpService: HttpService) {
/**
* Fetches pipelines associated with provided models
*
* @param modelId - Model ID, collection of Model IDs.
*/
getTrainedModelPipelines(modelId: string | string[]) {
@ -109,9 +107,31 @@ export function trainedModelsApiProvider(httpService: HttpService) {
});
},
/**
* Fetches all ingest pipelines
*/
getAllIngestPipelines() {
return httpService.http<NodesOverviewResponse>({
path: `${ML_INTERNAL_BASE_PATH}/trained_models/ingest_pipelines`,
method: 'GET',
version: '1',
});
},
/**
* Creates inference pipeline
*/
createInferencePipeline(pipelineName: string, pipeline: IngestPipeline) {
return httpService.http<estypes.IngestSimulateResponse>({
path: `${ML_INTERNAL_BASE_PATH}/trained_models/create_inference_pipeline`,
method: 'POST',
body: JSON.stringify({ pipeline, pipelineName }),
version: '1',
});
},
/**
* Deletes an existing trained inference model.
*
* @param modelId - Model ID
*/
deleteTrainedModel(

View file

@ -177,6 +177,8 @@
"DeleteTrainedModel",
"SimulateIngestPipeline",
"InferTrainedModelDeployment",
"CreateInferencePipeline",
"GetIngestPipelines",
"Alerting",
"PreviewAlert",

View file

@ -6,6 +6,11 @@
*/
import type { IScopedClusterClient } from '@kbn/core/server';
import {
IngestPipeline,
IngestSimulateDocument,
IngestSimulateRequest,
} from '@elastic/elasticsearch/lib/api/types';
import type { PipelineDefinition } from '../../../common/types/trained_models';
export type ModelService = ReturnType<typeof modelsProvider>;
@ -64,5 +69,64 @@ export function modelsProvider(client: IScopedClusterClient) {
pipelinesIds.map((id) => client.asCurrentUser.ingest.deletePipeline({ id }))
);
},
/**
* Simulates the effect of the pipeline on given document.
*
*/
async simulatePipeline(docs: IngestSimulateDocument[], pipelineConfig: IngestPipeline) {
const simulateRequest: IngestSimulateRequest = {
docs,
pipeline: pipelineConfig,
};
let result = {};
try {
result = await client.asCurrentUser.ingest.simulate(simulateRequest);
} catch (error) {
if (error.statusCode === 404) {
// ES returns 404 when there are no pipelines
// Instead, we should return an empty response and a 200
return result;
}
throw error;
}
return result;
},
/**
* Creates the pipeline
*
*/
async createInferencePipeline(pipelineConfig: IngestPipeline, pipelineName: string) {
let result = {};
result = await client.asCurrentUser.ingest.putPipeline({
id: pipelineName,
...pipelineConfig,
});
return result;
},
/**
* Retrieves existing pipelines.
*
*/
async getPipelines() {
let result = {};
try {
result = await client.asCurrentUser.ingest.getPipeline();
} catch (error) {
if (error.statusCode === 404) {
// ES returns 404 when there are no pipelines
// Instead, we should return an empty response and a 200
return result;
}
throw error;
}
return result;
},
};
}

View file

@ -77,3 +77,13 @@ export const deleteTrainedModelQuerySchema = schema.object({
with_pipelines: schema.maybe(schema.boolean({ defaultValue: false })),
force: schema.maybe(schema.boolean({ defaultValue: false })),
});
export const createIngestPipelineSchema = schema.object({
pipelineName: schema.string(),
pipeline: schema.maybe(
schema.object({
processors: schema.arrayOf(schema.any()),
description: schema.maybe(schema.string()),
})
),
});

View file

@ -22,6 +22,7 @@ import {
putTrainedModelQuerySchema,
threadingParamsSchema,
updateDeploymentParamsSchema,
createIngestPipelineSchema,
} from './schemas/inference_schema';
import { TrainedModelConfigResponse } from '../../common/types/trained_models';
import { mlLog } from '../lib/log';
@ -237,6 +238,78 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization)
})
);
/**
* @apiGroup TrainedModels
*
* @api {get} /internal/ml/trained_models/ingest_pipelines Get ingest pipelines
* @apiName GetIngestPipelines
* @apiDescription Retrieves pipelines
*/
router.versioned
.get({
path: `${ML_INTERNAL_BASE_PATH}/trained_models/ingest_pipelines`,
access: 'internal',
options: {
tags: ['access:ml:canGetTrainedModels'], // TODO: update permissions
},
})
.addVersion(
{
version: '1',
validate: false,
},
routeGuard.fullLicenseAPIGuard(async ({ client, request, mlClient, response }) => {
try {
const body = await modelsProvider(client).getPipelines();
return response.ok({
body,
});
} catch (e) {
return response.customError(wrapError(e));
}
})
);
/**
* @apiGroup TrainedModels
*
* @api {post} /internal/ml/trained_models/create_inference_pipeline creates the pipeline with inference processor
* @apiName CreateInferencePipeline
* @apiDescription Creates the inference pipeline
*/
router.versioned
.post({
path: `${ML_INTERNAL_BASE_PATH}/trained_models/create_inference_pipeline`,
access: 'internal',
options: {
tags: ['access:ml:canCreateTrainedModels'],
},
})
.addVersion(
{
version: '1',
validate: {
request: {
body: createIngestPipelineSchema,
},
},
},
routeGuard.fullLicenseAPIGuard(async ({ client, request, mlClient, response }) => {
try {
const { pipeline, pipelineName } = request.body;
const body = await modelsProvider(client).createInferencePipeline(
pipeline!,
pipelineName
);
return response.ok({
body,
});
} catch (e) {
return response.customError(wrapError(e));
}
})
);
/**
* @apiGroup TrainedModels
*