[ML] Transforms: Support to set destination ingest pipeline. (#123911)

* [ML] Support to set destination ingest pipeline.

* [ML] Fix jest tests.

* [ML] Fix permissions.

* [ML] Use EuiComboBox to be able to empty input.

* [ML] Add support to allow editing a transform destination ingest pipeline.

* [ML] Fix setting ingest pipeline field to optional.

* [ML] Fix translations.

* [ML] Fix functional tests.

* [ML] Fix unexposed form state.

* [ML] Tweaks.
This commit is contained in:
Walter Rafelsberger 2022-02-02 00:45:20 +01:00 committed by GitHub
parent 3efcf5218e
commit 815537afd4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 208 additions and 34 deletions

View file

@ -8,6 +8,7 @@
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { EsIndex } from '../types/es_index';
import type { EsIngestPipeline } from '../types/es_ingest_pipeline';
import { isPopulatedObject } from '../shared_imports';
// To be able to use the type guards on the client side, we need to make sure we don't import
@ -70,6 +71,10 @@ export const isEsIndices = (arg: unknown): arg is EsIndex[] => {
return Array.isArray(arg);
};
export const isEsIngestPipelines = (arg: unknown): arg is EsIngestPipeline[] => {
return Array.isArray(arg) && arg.every((d) => isPopulatedObject(d, ['name']));
};
export const isEsSearchResponse = (arg: unknown): arg is estypes.SearchResponse => {
return isPopulatedObject(arg, ['hits']);
};

View file

@ -43,7 +43,7 @@ export const API_BASE_PATH = '/api/transform/';
// In the UI additional privileges are required:
// - kibana_admin (builtin)
// - dest index: monitor (applied to df-*)
// - cluster: monitor
// - cluster: monitor, read_pipeline
//
// Note that users with kibana_admin can see all Kibana data views and saved searches
// in the source selection modal when creating a transform, but the wizard will trigger
@ -70,7 +70,7 @@ export const APP_GET_TRANSFORM_CLUSTER_PRIVILEGES = [
'cluster.cluster:monitor/transform/stats/get',
];
// Equivalent of capabilities.canGetTransform
// Equivalent of capabilities.canCreateTransform
export const APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES = [
'cluster.cluster:monitor/transform/get',
'cluster.cluster:monitor/transform/stats/get',

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
// This interface doesn't cover a full ingest pipeline spec,
// just what's necessary to make it work in the transform creation wizard.
// The full interface can be found in x-pack/plugins/ingest_pipelines/common/types.ts
export interface EsIngestPipeline {
name: string;
}

View file

@ -237,6 +237,7 @@ describe('Transform: Common', () => {
transformSettingsMaxPageSearchSize: 100,
transformSettingsDocsPerSecond: 400,
destinationIndex: 'the-destination-index',
destinationIngestPipeline: 'the-destination-ingest-pipeline',
touched: true,
valid: true,
};
@ -249,7 +250,7 @@ describe('Transform: Common', () => {
expect(request).toEqual({
description: 'the-transform-description',
dest: { index: 'the-destination-index' },
dest: { index: 'the-destination-index', pipeline: 'the-destination-ingest-pipeline' },
frequency: '1m',
pivot: {
aggregations: { 'the-agg-agg-name': { avg: { field: 'the-agg-field' } } },
@ -315,6 +316,7 @@ describe('Transform: Common', () => {
transformSettingsMaxPageSearchSize: 100,
transformSettingsDocsPerSecond: 400,
destinationIndex: 'the-destination-index',
destinationIngestPipeline: 'the-destination-ingest-pipeline',
touched: true,
valid: true,
};
@ -327,7 +329,7 @@ describe('Transform: Common', () => {
expect(request).toEqual({
description: 'the-transform-description',
dest: { index: 'the-destination-index' },
dest: { index: 'the-destination-index', pipeline: 'the-destination-ingest-pipeline' },
frequency: '1m',
pivot: {
aggregations: { 'the-agg-agg-name': { avg: { field: 'the-agg-field' } } },

View file

@ -219,6 +219,10 @@ export const getCreateTransformRequestBody = (
: {}),
dest: {
index: transformDetailsState.destinationIndex,
// conditionally add optional ingest pipeline
...(transformDetailsState.destinationIngestPipeline !== ''
? { pipeline: transformDetailsState.destinationIngestPipeline }
: {}),
},
// conditionally add continuous mode config
...(transformDetailsState.isContinuousModeEnabled

View file

@ -9,7 +9,7 @@ import { useMemo } from 'react';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { HttpFetchError } from 'kibana/public';
import type { HttpFetchError } from 'kibana/public';
import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/public';
@ -47,9 +47,10 @@ import type {
PostTransformsUpdateResponseSchema,
} from '../../../common/api_schemas/update_transforms';
import type { GetTransformsStatsResponseSchema } from '../../../common/api_schemas/transforms_stats';
import { TransformId } from '../../../common/types/transform';
import type { TransformId } from '../../../common/types/transform';
import { API_BASE_PATH } from '../../../common/constants';
import { EsIndex } from '../../../common/types/es_index';
import type { EsIndex } from '../../../common/types/es_index';
import type { EsIngestPipeline } from '../../../common/types/es_ingest_pipeline';
import { useAppDependencies } from '../app_dependencies';
@ -217,6 +218,13 @@ export const useApi = () => {
return e;
}
},
async getEsIngestPipelines(): Promise<EsIngestPipeline[] | HttpFetchError> {
try {
return await http.get('/api/ingest_pipelines');
} catch (e) {
return e;
}
},
async getHistogramsForFields(
indexPatternTitle: string,
fields: FieldHistogramRequestConfig[],

View file

@ -8,6 +8,7 @@
import type { TransformConfigUnion, TransformId } from '../../../../../../common/types/transform';
export type EsIndexName = string;
export type EsIngestPipelineName = string;
export type IndexPatternTitle = string;
export interface StepDetailsExposedState {
@ -15,6 +16,7 @@ export interface StepDetailsExposedState {
continuousModeDelay: string;
createIndexPattern: boolean;
destinationIndex: EsIndexName;
destinationIngestPipeline: EsIngestPipelineName;
isContinuousModeEnabled: boolean;
isRetentionPolicyEnabled: boolean;
retentionPolicyDateField: string;
@ -48,6 +50,7 @@ export function getDefaultStepDetailsState(): StepDetailsExposedState {
transformFrequency: defaultTransformFrequency,
transformSettingsMaxPageSearchSize: defaultTransformSettingsMaxPageSearchSize,
destinationIndex: '',
destinationIngestPipeline: '',
touched: false,
valid: false,
indexPatternTimeField: undefined,
@ -73,6 +76,11 @@ export function applyTransformConfigToDetailsState(
state.transformDescription = transformConfig.description;
}
// Ingest Pipeline
if (transformConfig.dest.pipeline !== undefined) {
state.destinationIngestPipeline = transformConfig.dest.pipeline;
}
// Frequency
if (transformConfig.frequency !== undefined) {
state.transformFrequency = transformConfig.frequency;

View file

@ -12,6 +12,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiAccordion,
EuiComboBox,
EuiLink,
EuiSwitch,
EuiFieldText,
@ -28,6 +29,7 @@ import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_reac
import {
isEsIndices,
isEsIngestPipelines,
isPostTransformsPreviewResponseSchema,
} from '../../../../../../common/api_schemas/type_guards';
import { TransformId } from '../../../../../../common/types/transform';
@ -82,8 +84,12 @@ export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo(
const [destinationIndex, setDestinationIndex] = useState<EsIndexName>(
defaults.destinationIndex
);
const [destinationIngestPipeline, setDestinationIngestPipeline] = useState<string>(
defaults.destinationIngestPipeline
);
const [transformIds, setTransformIds] = useState<TransformId[]>([]);
const [indexNames, setIndexNames] = useState<EsIndexName[]>([]);
const [ingestPipelineNames, setIngestPipelineNames] = useState<string[]>([]);
const canCreateDataView = useMemo(
() =>
@ -180,7 +186,10 @@ export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo(
setTransformIds(resp.transforms.map((transform) => transform.id));
}
const indices = await api.getEsIndices();
const [indices, ingestPipelines] = await Promise.all([
api.getEsIndices(),
api.getEsIngestPipelines(),
]);
if (isEsIndices(indices)) {
setIndexNames(indices.map((index) => index.name));
@ -200,6 +209,24 @@ export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo(
});
}
if (isEsIngestPipelines(ingestPipelines)) {
setIngestPipelineNames(ingestPipelines.map(({ name }) => name));
} else {
toastNotifications.addDanger({
title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingIngestPipelines', {
defaultMessage: 'An error occurred getting the existing ingest pipeline names:',
}),
text: toMountPoint(
<ToastNotificationText
overlays={overlays}
theme={theme}
text={getErrorMessage(ingestPipelines)}
/>,
{ theme$: theme.theme$ }
),
});
}
try {
setIndexPatternTitles(await deps.data.indexPatterns.getTitles());
} catch (e) {
@ -311,6 +338,7 @@ export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo(
transformSettingsMaxPageSearchSize,
transformSettingsDocsPerSecond,
destinationIndex,
destinationIngestPipeline,
touched: true,
valid,
indexPatternTimeField,
@ -331,6 +359,7 @@ export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo(
transformFrequency,
transformSettingsMaxPageSearchSize,
destinationIndex,
destinationIngestPipeline,
valid,
indexPatternTimeField,
/* eslint-enable react-hooks/exhaustive-deps */
@ -443,6 +472,37 @@ export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo(
/>
</EuiFormRow>
{ingestPipelineNames.length > 0 && (
<EuiFormRow
label={i18n.translate(
'xpack.transform.stepDetailsForm.destinationIngestPipelineLabel',
{
defaultMessage: 'Destination ingest pipeline',
}
)}
>
<EuiComboBox
data-test-subj="transformDestinationPipelineSelect"
aria-label={i18n.translate(
'xpack.transform.stepDetailsForm.destinationIngestPipelineAriaLabel',
{
defaultMessage: 'Select an ingest pipeline',
}
)}
placeholder={i18n.translate(
'xpack.transform.stepDetailsForm.destinationIngestPipelineComboBoxPlaceholder',
{
defaultMessage: 'Select an ingest pipeline',
}
)}
singleSelection={{ asPlainText: true }}
options={ingestPipelineNames.map((label: string) => ({ label }))}
selectedOptions={[{ label: destinationIngestPipeline }]}
onChange={(options) => setDestinationIngestPipeline(options[0]?.label ?? '')}
/>
</EuiFormRow>
)}
{stepDefineState.transformFunction === TRANSFORM_FUNCTION.LATEST ? (
<>
<EuiSpacer size={'m'} />

View file

@ -7,13 +7,16 @@
import React, { FC, useEffect, useMemo, useState } from 'react';
import { EuiForm, EuiAccordion, EuiSpacer, EuiSelect, EuiFormRow } from '@elastic/eui';
import { EuiComboBox, EuiForm, EuiAccordion, EuiSpacer, EuiSelect, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isEsIngestPipelines } from '../../../../../../common/api_schemas/type_guards';
import { EditTransformFlyoutFormTextInput } from './edit_transform_flyout_form_text_input';
import { UseEditTransformFlyoutReturnType } from './use_edit_transform_flyout';
import { useAppDependencies } from '../../../../app_dependencies';
import { useApi } from '../../../../hooks/use_api';
import { KBN_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/common';
interface EditTransformFlyoutFormProps {
@ -27,9 +30,11 @@ export const EditTransformFlyoutForm: FC<EditTransformFlyoutFormProps> = ({
}) => {
const formFields = state.formFields;
const [dateFieldNames, setDateFieldNames] = useState<string[]>([]);
const [ingestPipelineNames, setIngestPipelineNames] = useState<string[]>([]);
const appDeps = useAppDependencies();
const indexPatternsClient = appDeps.data.indexPatterns;
const api = useApi();
useEffect(
function getDateFields() {
@ -54,6 +59,25 @@ export const EditTransformFlyoutForm: FC<EditTransformFlyoutFormProps> = ({
[indexPatternId, indexPatternsClient]
);
useEffect(function fetchPipelinesOnMount() {
let unmounted = false;
async function getIngestPipelineNames() {
const ingestPipelines = await api.getEsIngestPipelines();
if (!unmounted && isEsIngestPipelines(ingestPipelines)) {
setIngestPipelineNames(ingestPipelines.map(({ name }) => name));
}
}
getIngestPipelineNames();
return () => {
unmounted = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const retentionDateFieldOptions = useMemo(() => {
return Array.isArray(dateFieldNames)
? dateFieldNames.map((text: string) => ({ text, value: text }))
@ -120,18 +144,61 @@ export const EditTransformFlyoutForm: FC<EditTransformFlyoutFormProps> = ({
value={formFields.destinationIndex.value}
/>
<EditTransformFlyoutFormTextInput
dataTestSubj="transformEditFlyoutDestinationPipelineInput"
errorMessages={formFields.destinationPipeline.errorMessages}
label={i18n.translate(
'xpack.transform.transformList.editFlyoutFormDestinationPipelineLabel',
{
defaultMessage: 'Pipeline',
}
)}
onChange={(value) => dispatch({ field: 'destinationPipeline', value })}
value={formFields.destinationPipeline.value}
/>
<EuiSpacer size="m" />
<div data-test-subj="transformEditAccordionIngestPipelineContent">
{
// If the list of ingest pipelines is not available
// gracefully defaults to text input
ingestPipelineNames ? (
<EuiFormRow
label={i18n.translate(
'xpack.transform.transformList.editFlyoutFormDestinationIngestPipelineLabel',
{
defaultMessage: 'Ingest Pipeline',
}
)}
isInvalid={formFields.destinationIngestPipeline.errorMessages.length > 0}
error={formFields.destinationIngestPipeline.errorMessages}
>
<EuiComboBox
data-test-subj="transformEditFlyoutDestinationIngestPipelineFieldSelect"
aria-label={i18n.translate(
'xpack.transform.stepDetailsForm.editFlyoutFormDestinationIngestPipelineFieldSelectAriaLabel',
{
defaultMessage: 'Select an ingest pipeline',
}
)}
placeholder={i18n.translate(
'xpack.transform.stepDetailsForm.editFlyoutFormDestinationIngestPipelineFieldSelectPlaceholder',
{
defaultMessage: 'Select an ingest pipeline',
}
)}
singleSelection={{ asPlainText: true }}
options={ingestPipelineNames.map((label: string) => ({ label }))}
selectedOptions={[{ label: formFields.destinationIngestPipeline.value }]}
onChange={(o) =>
dispatch({ field: 'destinationIngestPipeline', value: o[0]?.label ?? '' })
}
/>
</EuiFormRow>
) : (
<EditTransformFlyoutFormTextInput
dataTestSubj="transformEditFlyoutDestinationIngestPipelineInput"
errorMessages={formFields.destinationIngestPipeline.errorMessages}
label={i18n.translate(
'xpack.transform.transformList.editFlyoutFormDestinationIngestPipelineLabel',
{
defaultMessage: 'Ingest Pipeline',
}
)}
onChange={(value) => dispatch({ field: 'destinationIngestPipeline', value })}
value={formFields.destinationIngestPipeline.value}
/>
)
}
</div>
</div>
</EuiAccordion>

View file

@ -30,11 +30,11 @@ import {
// The outer most level reducer defines a flat structure of names for form fields.
// This is a flat structure regardless of whether the final request object will be nested.
// For example, `destinationIndex` and `destinationPipeline` will later be nested under `dest`.
// For example, `destinationIndex` and `destinationIngestPipeline` will later be nested under `dest`.
type EditTransformFormFields =
| 'description'
| 'destinationIndex'
| 'destinationPipeline'
| 'destinationIngestPipeline'
| 'frequency'
| 'docsPerSecond'
| 'maxPageSearchSize'
@ -300,12 +300,18 @@ export const getDefaultState = (config: TransformConfigUnion): EditTransformFlyo
// dest.*
destinationIndex: initializeField('destinationIndex', 'dest.index', config, {
dependsOn: ['destinationPipeline'],
dependsOn: ['destinationIngestPipeline'],
isOptional: false,
}),
destinationPipeline: initializeField('destinationPipeline', 'dest.pipeline', config, {
dependsOn: ['destinationIndex'],
}),
destinationIngestPipeline: initializeField(
'destinationIngestPipeline',
'dest.pipeline',
config,
{
dependsOn: ['destinationIndex'],
isOptional: true,
}
),
// settings.*
docsPerSecond: initializeField('docsPerSecond', 'settings.docs_per_second', config, {

View file

@ -25981,7 +25981,7 @@
"xpack.transform.transformList.editFlyoutFormDescriptionLabel": "説明",
"xpack.transform.transformList.editFlyoutFormDestinationButtonContent": "ディスティネーション構成",
"xpack.transform.transformList.editFlyoutFormDestinationIndexLabel": "デスティネーションインデックス",
"xpack.transform.transformList.editFlyoutFormDestinationPipelineLabel": "パイプライン",
"xpack.transform.transformList.editFlyoutFormDestinationIngestPipelineLabel": "パイプライン",
"xpack.transform.transformList.editFlyoutFormDocsPerSecondHelptext": "スロットリングを有効にするには、毎秒入力するドキュメントの上限を設定します。",
"xpack.transform.transformList.editFlyoutFormDocsPerSecondLabel": "毎秒あたりのドキュメント",
"xpack.transform.transformList.editFlyoutFormFrequencyHelpText": "変換が連続実行されているときにソースインデックスで変更を確認する間の間隔。また、変換が検索またはインデックス中に一時障害が発生した場合に、再試行する間隔も決定します。最小値は1秒で、最大値は1時間です。",

View file

@ -26442,7 +26442,7 @@
"xpack.transform.transformList.editFlyoutFormDescriptionLabel": "描述",
"xpack.transform.transformList.editFlyoutFormDestinationButtonContent": "目标配置",
"xpack.transform.transformList.editFlyoutFormDestinationIndexLabel": "目标索引",
"xpack.transform.transformList.editFlyoutFormDestinationPipelineLabel": "管道",
"xpack.transform.transformList.editFlyoutFormDestinationIngestPipelineLabel": "管道",
"xpack.transform.transformList.editFlyoutFormDocsPerSecondHelptext": "要启用节流,请设置每秒要输入的文档限值。",
"xpack.transform.transformList.editFlyoutFormDocsPerSecondLabel": "每秒文档数",
"xpack.transform.transformList.editFlyoutFormFrequencyHelpText": "在转换不间断地执行时检查源索引更改的时间间隔。还确定在转换搜索或索引时发生暂时失败时的重试时间间隔。最小值为 1 秒,最大值为 1 小时。",

View file

@ -151,10 +151,7 @@ export default function ({ getService }: FtrProviderContext) {
await transform.testExecution.logTestStep('should have the destination inputs enabled');
await transform.editFlyout.openTransformEditAccordionDestinationSettings();
await transform.editFlyout.assertTransformEditFlyoutInputEnabled('DestinationIndex', true);
await transform.editFlyout.assertTransformEditFlyoutInputEnabled(
'DestinationPipeline',
true
);
await transform.editFlyout.assertTransformEditFlyoutIngestPipelineFieldSelectExists();
await transform.testExecution.logTestStep(
'should have the retention policy inputs enabled'

View file

@ -37,6 +37,10 @@ export function TransformEditFlyoutProvider({ getService }: FtrProviderContext)
);
},
async assertTransformEditFlyoutIngestPipelineFieldSelectExists() {
await testSubjects.existOrFail(`transformEditFlyoutDestinationIngestPipelineFieldSelect`);
},
async assertTransformEditFlyoutRetentionPolicyFieldSelectEnabled(expectedValue: boolean) {
await testSubjects.existOrFail(`transformEditFlyoutRetentionPolicyFieldSelect`, {
timeout: 1000,

View file

@ -45,7 +45,7 @@ export function TransformSecurityCommonProvider({ getService }: FtrProviderConte
{
name: 'transform_ui_extras',
elasticsearch: {
cluster: ['monitor'],
cluster: ['monitor', 'read_pipeline'],
},
kibana: [],
},