[8.x] [Ingest Pipelines] Offer create non existing custom pipeline (#209103) (#210214)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Ingest Pipelines] Offer create non existing custom pipeline
(#209103)](https://github.com/elastic/kibana/pull/209103)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Sonia Sanz
Vivas","email":"sonia.sanzvivas@elastic.co"},"sourceCommit":{"committedDate":"2025-02-07T16:10:28Z","message":"[Ingest
Pipelines] Offer create non existing custom pipeline (#209103)\n\nFixes
https://github.com/elastic/kibana/issues/183992","sha":"9a06509972a976b471cef5af496690ff6200223e","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Kibana
Management","release_note:skip","Feature:Ingest Node
Pipelines","backport:prev-minor","v9.1.0","v8.19.0"],"title":"[Ingest
Pipelines] Offer create non existing custom
pipeline","number":209103,"url":"https://github.com/elastic/kibana/pull/209103","mergeCommit":{"message":"[Ingest
Pipelines] Offer create non existing custom pipeline (#209103)\n\nFixes
https://github.com/elastic/kibana/issues/183992","sha":"9a06509972a976b471cef5af496690ff6200223e"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/209103","number":209103,"mergeCommit":{"message":"[Ingest
Pipelines] Offer create non existing custom pipeline (#209103)\n\nFixes
https://github.com/elastic/kibana/issues/183992","sha":"9a06509972a976b471cef5af496690ff6200223e"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Sonia Sanz Vivas <sonia.sanzvivas@elastic.co>
This commit is contained in:
Kibana Machine 2025-02-08 04:59:10 +11:00 committed by GitHub
parent c0e3e39309
commit 40927246f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 254 additions and 74 deletions

View file

@ -20,4 +20,5 @@ export interface Error {
error: string;
cause?: string[];
message?: string;
statusCode?: number;
}

View file

@ -25032,7 +25032,6 @@
"xpack.ingestPipelines.list.loadingMessage": "Chargement des pipelines...",
"xpack.ingestPipelines.list.loadPipelineReloadButton": "Réessayer",
"xpack.ingestPipelines.list.manageProcessorsLinkText": "Gérer les processeurs",
"xpack.ingestPipelines.list.notFoundFlyoutMessage": "Pipeline introuvable",
"xpack.ingestPipelines.list.pipelineDetails.cloneActionLabel": "Cloner",
"xpack.ingestPipelines.list.pipelineDetails.closeButtonLabel": "Fermer",
"xpack.ingestPipelines.list.pipelineDetails.deleteActionLabel": "Supprimer",

View file

@ -24890,7 +24890,6 @@
"xpack.ingestPipelines.list.loadingMessage": "パイプラインを読み込み中...",
"xpack.ingestPipelines.list.loadPipelineReloadButton": "再試行",
"xpack.ingestPipelines.list.manageProcessorsLinkText": "プロセッサーを管理",
"xpack.ingestPipelines.list.notFoundFlyoutMessage": "パイプラインが見つかりません",
"xpack.ingestPipelines.list.pipelineDetails.cloneActionLabel": "クローンを作成",
"xpack.ingestPipelines.list.pipelineDetails.closeButtonLabel": "閉じる",
"xpack.ingestPipelines.list.pipelineDetails.deleteActionLabel": "削除",

View file

@ -24977,7 +24977,6 @@
"xpack.ingestPipelines.list.loadingMessage": "正在加载管道……",
"xpack.ingestPipelines.list.loadPipelineReloadButton": "重试",
"xpack.ingestPipelines.list.manageProcessorsLinkText": "管理处理器",
"xpack.ingestPipelines.list.notFoundFlyoutMessage": "未找到管道",
"xpack.ingestPipelines.list.pipelineDetails.cloneActionLabel": "克隆",
"xpack.ingestPipelines.list.pipelineDetails.closeButtonLabel": "关闭",
"xpack.ingestPipelines.list.pipelineDetails.deleteActionLabel": "删除",

View file

@ -13,6 +13,7 @@ export interface ResponseError {
statusCode: number;
message: string | Error;
attributes?: Record<string, any>;
error?: string | Error;
}
// Register helpers to mock HTTP Requests
@ -59,7 +60,7 @@ const registerHttpRequestMockHelpers = (
pipelineName: string,
response?: object,
error?: ResponseError
) => mockResponse('GET', `${API_BASE_PATH}/${pipelineName}`, response, error);
) => mockResponse('GET', `${API_BASE_PATH}/${encodeURIComponent(pipelineName)}`, response, error);
const setDeletePipelineResponse = (
pipelineName: string,

View file

@ -18,14 +18,6 @@ import { PipelinesList } from '../../../public/application/sections/pipelines_li
import { WithAppDependencies } from './setup_environment';
import { getListPath, ROUTES } from '../../../public/application/services/navigation';
const testBedConfig: AsyncTestBedConfig = {
memoryRouter: {
initialEntries: [getListPath()],
componentRoutePath: ROUTES.list,
},
doMountAsync: true,
};
export type PipelineListTestBed = TestBed<PipelineListTestSubjects> & {
actions: ReturnType<typeof createActions>;
};
@ -88,7 +80,18 @@ const createActions = (testBed: TestBed) => {
};
};
export const setup = async (httpSetup: HttpSetup): Promise<PipelineListTestBed> => {
export const setup = async (
httpSetup: HttpSetup,
queryParams: string = ''
): Promise<PipelineListTestBed> => {
const testBedConfig: AsyncTestBedConfig = {
memoryRouter: {
initialEntries: [`${getListPath()}${queryParams}`],
componentRoutePath: ROUTES.list,
},
doMountAsync: true,
};
const initTestBed = registerTestBed(WithAppDependencies(PipelinesList, httpSetup), testBedConfig);
const testBed = await initTestBed();
@ -111,4 +114,11 @@ export type PipelineListTestSubjects =
| 'sectionLoading'
| 'pipelineLoadError'
| 'jsonCodeBlock'
| 'reloadButton';
| 'reloadButton'
| 'pipelineErrorFlyout'
| 'pipelineErrorFlyout.title'
| 'pipelineError'
| 'pipelineError.cause'
| 'missingCustomPipeline'
| 'missingCustomPipeline.cause'
| 'createCustomPipeline';

View file

@ -126,16 +126,37 @@ describe('<PipelinesList />', () => {
test('should show the details of a pipeline', async () => {
const { find, exists, actions } = testBed;
const { name: pipelineName } = pipeline1;
httpRequestsMockHelpers.setLoadPipelineResponse(pipelineName, pipeline1);
await actions.clickPipelineAt(0);
expect(exists('pipelinesTable')).toBe(true);
expect(exists('pipelineDetails')).toBe(true);
expect(find('pipelineDetails.title').text()).toBe(pipeline1.name);
expect(find('pipelineDetails.title').text()).toBe(pipelineName);
});
test('should show load details of a pipeline if added to the url', async () => {
const { name: pipelineName } = pipeline1;
httpRequestsMockHelpers.setLoadPipelineResponse(pipelineName, pipeline1);
await act(async () => {
testBed = await setup(httpSetup, `?pipeline=${pipelineName}`);
});
testBed.component.update();
const { find, exists } = testBed;
expect(exists('pipelinesTable')).toBe(true);
expect(exists('pipelineDetails')).toBe(true);
expect(find('pipelineDetails.title').text()).toBe(pipelineName);
});
test('Replaces newline characters for spaces in flyout for json blocks', async () => {
const { find, actions } = testBed;
httpRequestsMockHelpers.setLoadPipelineResponse(pipeline2.name, pipeline2);
await actions.clickPipelineAt(1);
@ -174,6 +195,54 @@ describe('<PipelinesList />', () => {
expect.anything()
);
});
describe('Pipeline error flyout', () => {
const error = {
statusCode: 404,
message: 'Not Found',
error: 'Not Found',
};
test('should render an error message flyout if error fetching pipeline', async () => {
const nonExistingPipeline = 'nonExistingPipeline';
httpRequestsMockHelpers.setLoadPipelineResponse(nonExistingPipeline, {}, error);
await act(async () => {
testBed = await setup(httpSetup, `?pipeline=${nonExistingPipeline}`);
});
testBed.component.update();
const { find, exists } = testBed;
expect(exists('pipelinesTable')).toBe(true);
expect(exists('pipelineErrorFlyout')).toBe(true);
expect(find('pipelineErrorFlyout.title').text()).toBe(nonExistingPipeline);
expect(exists('pipelineError')).toBe(true);
expect(find('pipelineError.cause').text()).toBe('Not Found');
});
test('should render a create pipeline warning if @custom pipeline does not exist', async () => {
const customPipeline = 'pipeline@custom';
httpRequestsMockHelpers.setLoadPipelineResponse(customPipeline, {}, error);
await act(async () => {
testBed = await setup(httpSetup, `?pipeline=${customPipeline}`);
});
testBed.component.update();
const { find, exists } = testBed;
expect(exists('pipelinesTable')).toBe(true);
expect(exists('pipelineErrorFlyout')).toBe(true);
expect(find('pipelineErrorFlyout.title').text()).toBe(customPipeline);
expect(exists('missingCustomPipeline')).toBe(true);
expect(find('missingCustomPipeline.cause').text()).toBe(
`The pipeline ${customPipeline} does not exist.`
);
expect(exists('createCustomPipeline')).toBe(true);
});
});
});
describe('No pipelines', () => {

View file

@ -37,10 +37,9 @@ import { useCheckManageProcessorsPrivileges } from '../manage_processors';
import { EmptyList } from './empty_list';
import { PipelineTable } from './table';
import { PipelineDetailsFlyout } from './details_flyout';
import { PipelineNotFoundFlyout } from './not_found_flyout';
import { PipelineDeleteModal } from './delete_modal';
import { getErrorText } from '../utils';
import { PipelineFlyout } from './pipeline_flyout';
const getPipelineNameFromLocation = (location: Location) => {
const { pipeline } = parse(location.search.substring(1));
@ -54,7 +53,6 @@ export const PipelinesList: React.FunctionComponent<RouteComponentProps> = ({
const { services } = useKibana();
const pipelineNameFromLocation = getPipelineNameFromLocation(location);
const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | undefined>(undefined);
const [showFlyout, setShowFlyout] = useState<boolean>(false);
const [showPopover, setShowPopover] = useState<boolean>(false);
@ -71,8 +69,6 @@ export const PipelinesList: React.FunctionComponent<RouteComponentProps> = ({
useEffect(() => {
if (pipelineNameFromLocation && data?.length) {
const pipeline = data.find((p) => p.name === pipelineNameFromLocation);
setSelectedPipeline(pipeline);
setShowFlyout(true);
}
}, [pipelineNameFromLocation, data]);
@ -227,30 +223,6 @@ export const PipelinesList: React.FunctionComponent<RouteComponentProps> = ({
</EuiButtonEmpty>
);
const renderFlyout = (): React.ReactNode => {
if (!showFlyout) {
return;
}
if (selectedPipeline) {
return (
<PipelineDetailsFlyout
pipeline={selectedPipeline}
onClose={() => {
setSelectedPipeline(undefined);
goHome();
}}
onEditClick={goToEditPipeline}
onCloneClick={goToClonePipeline}
onDeleteClick={setPipelinesToDelete}
/>
);
} else {
// Somehow we triggered show pipeline details, but do not have a pipeline.
// We assume not found.
return <PipelineNotFoundFlyout onClose={goHome} pipelineName={pipelineNameFromLocation} />;
}
};
return (
<>
<EuiPageHeader
@ -283,14 +255,24 @@ export const PipelinesList: React.FunctionComponent<RouteComponentProps> = ({
pipelines={data as Pipeline[]}
/>
{renderFlyout()}
{showFlyout && (
<PipelineFlyout
pipeline={pipelineNameFromLocation}
onClose={() => {
goHome();
}}
onEditClick={goToEditPipeline}
onCloneClick={goToClonePipeline}
onDeleteClick={setPipelinesToDelete}
/>
)}
{pipelinesToDelete?.length > 0 ? (
<PipelineDeleteModal
callback={(deleteResponse) => {
if (deleteResponse?.hasDeletedPipelines) {
// reload pipelines list
resendRequest();
setSelectedPipeline(undefined);
goHome();
}
setPipelinesToDelete([]);

View file

@ -7,37 +7,94 @@
import React, { FunctionComponent } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiFlyout, EuiFlyoutBody, EuiCallOut } from '@elastic/eui';
import { EuiFlyout, EuiFlyoutBody, EuiCallOut, EuiCode, EuiButton } from '@elastic/eui';
import { EuiFlyoutHeader, EuiTitle } from '@elastic/eui';
import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public';
import { Error, useKibana } from '../../../shared_imports';
import { getCreatePath } from '../../services/navigation';
import { getErrorText, isIntegrationsPipeline } from '../utils';
interface Props {
onClose: () => void;
pipelineName: string | string[] | null | undefined;
pipelineName: string;
error: Error;
}
export const PipelineNotFoundFlyout: FunctionComponent<Props> = ({ onClose, pipelineName }) => {
export const PipelineNotFoundFlyout: FunctionComponent<Props> = ({
onClose,
pipelineName,
error,
}) => {
const { history } = useKibana().services;
const renderErrorCallOut = () => {
if (error.statusCode === 404 && isIntegrationsPipeline(pipelineName)) {
return (
<EuiCallOut
title={
<FormattedMessage
id="xpack.ingestPipelines.list.missingCustomPipeline.title"
defaultMessage="Custom pipeline does not exist"
/>
}
color="warning"
iconType="warning"
data-test-subj="missingCustomPipeline"
>
<p data-test-subj="cause">
<FormattedMessage
id="xpack.ingestPipelines.list.missingCustomPipeline.text"
defaultMessage="The pipeline {pipelineName} does not exist."
values={{
pipelineName: <EuiCode>{pipelineName}</EuiCode>,
}}
/>
</p>
<EuiButton
color="warning"
{...reactRouterNavigate(
history,
getCreatePath({
pipelineName,
})
)}
data-test-subj="createCustomPipeline"
>
<FormattedMessage
id="xpack.ingestPipelines.list.missingCustomPipeline.button"
defaultMessage="Create pipeline"
/>
</EuiButton>
</EuiCallOut>
);
}
return (
<EuiCallOut
title={
<FormattedMessage
id="xpack.ingestPipelines.list.loadingError"
defaultMessage="Error loading pipeline"
/>
}
color="danger"
iconType="warning"
data-test-subj="pipelineError"
>
<p data-test-subj="cause">{getErrorText(error)}</p>
</EuiCallOut>
);
};
return (
<EuiFlyout onClose={onClose} size="m" maxWidth={550}>
<EuiFlyout onClose={onClose} size="m" maxWidth={550} data-test-subj="pipelineErrorFlyout">
<EuiFlyoutHeader>
{pipelineName && (
<EuiTitle id="notFoundFlyoutTitle">
<EuiTitle id="notFoundFlyoutTitle" data-test-subj="title">
<h2>{pipelineName}</h2>
</EuiTitle>
)}
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiCallOut
title={
<FormattedMessage
id="xpack.ingestPipelines.list.notFoundFlyoutMessage"
defaultMessage="Pipeline not found"
/>
}
color="danger"
iconType="warning"
/>
</EuiFlyoutBody>
<EuiFlyoutBody>{renderErrorCallOut()} </EuiFlyoutBody>
</EuiFlyout>
);
};

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FunctionComponent } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { SectionLoading, useKibana } from '../../../shared_imports';
import { Pipeline } from '../../../../common/types';
import { PipelineDetailsFlyout } from './details_flyout';
import { PipelineNotFoundFlyout } from './not_found_flyout';
export interface Props {
pipeline: string | string[] | null | undefined;
onEditClick: (pipelineName: string) => void;
onCloneClick: (pipelineName: string) => void;
onDeleteClick: (pipelineName: Pipeline[]) => void;
onClose: () => void;
}
export const PipelineFlyout: FunctionComponent<Props> = ({
pipeline,
onClose,
onEditClick,
onCloneClick,
onDeleteClick,
}) => {
const { services } = useKibana();
const pipelineName = pipeline && typeof pipeline === 'string' ? pipeline : '';
const { data, isLoading, error } = services.api.useLoadPipeline(pipelineName);
return (
<>
{isLoading && (
<SectionLoading>
<FormattedMessage
id="xpack.ingestPipelines.list.pipelineDetails.loading"
defaultMessage="Loading pipeline…"
/>
</SectionLoading>
)}
{error && (
<PipelineNotFoundFlyout pipelineName={pipelineName} onClose={onClose} error={error} />
)}
{data && (
<PipelineDetailsFlyout
pipeline={data}
onClose={onClose}
onEditClick={onEditClick}
onCloneClick={onCloneClick}
onDeleteClick={onDeleteClick}
/>
)}
</>
);
};

View file

@ -10,6 +10,7 @@ import qs from 'query-string';
import { i18n } from '@kbn/i18n';
import { isEmpty, omit } from 'lodash';
import { FormattedMessage } from '@kbn/i18n-react';
import { parse } from 'query-string';
import {
EuiInMemoryTable,
@ -124,7 +125,6 @@ export const PipelineTable: FunctionComponent<Props> = ({
initialSort: { field: 'name', direction: 'asc' },
pageSizeOptions: PAGE_SIZE_OPTIONS,
});
const filteredPipelines = useMemo(() => {
// Filter pipelines list by whatever the user entered in the search bar
const pipelinesAfterSearch = (pipelines || []).filter((pipeline) => {
@ -152,7 +152,6 @@ export const PipelineTable: FunctionComponent<Props> = ({
deprecated,
managed,
} = qs.parse(history?.location?.search || '');
if (searchQuery) {
setQueryText(searchQuery as string);
}
@ -172,12 +171,8 @@ export const PipelineTable: FunctionComponent<Props> = ({
const isDefaultFilters = isDefaultFilterOptions(serializedFilterOptions);
const isDefaultFilterConfiguration = isQueryEmpty && isDefaultFilters;
// When the default filters are set, clear them up from the url
if (isDefaultFilterConfiguration) {
history.push('');
} else {
// Otherwise, we can go ahead and update the query params with whatever
// the user has set.
if (!isDefaultFilterConfiguration) {
const { pipeline } = parse(location.search.substring(1));
history.push({
pathname: '',
search:
@ -186,6 +181,7 @@ export const PipelineTable: FunctionComponent<Props> = ({
{
...(!isQueryEmpty ? { queryText } : {}),
...(!isDefaultFilters ? serializedFilterOptions : {}),
...(pipeline ? { pipeline } : {}),
},
{ strict: false, arrayFormat: 'index' }
),

View file

@ -10,3 +10,8 @@ import { Error } from '../../shared_imports';
export const getErrorText = (error: Error) => {
return error.message && error.message !== '{}' ? error.message : error.error;
};
// All pipleines for integrations end in @custom
export const isIntegrationsPipeline = (name: string) => {
return name.toLowerCase().endsWith('@custom');
};

View file

@ -19,8 +19,8 @@ const _getEditPath = (name: string, encode = true): string => {
return `${BASE_PATH}${EDIT_PATH}/${encode ? encodeURIComponent(name) : name}`;
};
const _getCreatePath = (): string => {
return `${BASE_PATH}${CREATE_PATH}`;
const _getCreatePath = (name?: string): string => {
return `${BASE_PATH}${CREATE_PATH}${name ? `?name=${encodeURIComponent(name)}` : ''}`;
};
const _getClonePath = (name: string, encode = true): string => {
@ -55,7 +55,9 @@ export const getListPath = ({
} = {}): string => _getListPath(inspectedPipelineName);
export const getEditPath = ({ pipelineName }: { pipelineName: string }): string =>
_getEditPath(pipelineName, true);
export const getCreatePath = (): string => _getCreatePath();
export const getCreatePath = ({ pipelineName }: { pipelineName?: string } = {}): string =>
_getCreatePath(pipelineName);
export const getClonePath = ({ clonedPipelineName }: { clonedPipelineName: string }): string =>
_getClonePath(clonedPipelineName, true);
export const getCreateFromCsvPath = (): string => _getCreateFromCsvPath();