[Streams 🌊] Enrichment UX redesign (#208141)

## 📓 Summary

Closes https://github.com/elastic/streams-program/issues/67
Closes https://github.com/elastic/streams-program/issues/69
Closes https://github.com/elastic/streams-program/issues/93
Closes https://github.com/elastic/streams-program/issues/75

This work heavily changes the initial prototype of the stream enrichment
section.
- Update the design into a unified split view.
- Introduce auto-simulation for real-time changes
- Differentiate between saved and staged processors, with
multi-processor simulation for the draft ones.

A downgrade versus the previous experience is the removal of the field
mapping selectors and simulation.
This is a temporary change, as we want to set a detected fields tab in
the simulation panel that embeds the schema editor, which is not ready
for this yet.


https://github.com/user-attachments/assets/6ea172b1-087f-4fd0-a850-b6dddc5ca311
This commit is contained in:
Marco Antonio Ghiani 2025-02-05 12:27:50 +01:00 committed by GitHub
parent 0d9ce86d0b
commit 8d0f3544f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1440 additions and 1596 deletions

View file

@ -6,7 +6,6 @@
*/
export * from './ingest';
export * from './legacy';
export * from './api';
export * from './core';

View file

@ -11,14 +11,15 @@ import { Condition, conditionSchema } from '../conditions';
import { createIsNarrowSchema } from '../../../helpers';
export interface ProcessorBase {
description?: string;
if: Condition;
ignore_failure?: boolean;
}
export interface GrokProcessorConfig extends ProcessorBase {
field: string;
patterns: string[];
pattern_definitions?: Record<string, string>;
ignore_failure?: boolean;
ignore_missing?: boolean;
}
@ -27,7 +28,9 @@ export interface GrokProcessorDefinition {
}
const processorBaseSchema = z.object({
description: z.optional(z.string()),
if: conditionSchema,
ignore_failure: z.optional(z.boolean()),
});
export const grokProcessorDefinitionSchema: z.Schema<GrokProcessorDefinition> = z.strictObject({
@ -35,9 +38,8 @@ export const grokProcessorDefinitionSchema: z.Schema<GrokProcessorDefinition> =
processorBaseSchema,
z.object({
field: NonEmptyString,
patterns: z.array(NonEmptyString),
patterns: z.array(NonEmptyString).nonempty(),
pattern_definitions: z.optional(z.record(z.string())),
ignore_failure: z.optional(z.boolean()),
ignore_missing: z.optional(z.boolean()),
})
),
@ -47,7 +49,6 @@ export interface DissectProcessorConfig extends ProcessorBase {
field: string;
pattern: string;
append_separator?: string;
ignore_failure?: boolean;
ignore_missing?: boolean;
}
@ -63,7 +64,6 @@ export const dissectProcessorDefinitionSchema: z.Schema<DissectProcessorDefiniti
field: NonEmptyString,
pattern: NonEmptyString,
append_separator: z.optional(NonEmptyString),
ignore_failure: z.optional(z.boolean()),
ignore_missing: z.optional(z.boolean()),
})
),
@ -78,7 +78,7 @@ export type ProcessorConfig = BodyOf<ProcessorDefinition>;
export type ProcessorType = UnionKeysOf<ProcessorDefinition>;
type ProcessorTypeOf<TProcessorDefinition extends ProcessorDefinition> =
export type ProcessorTypeOf<TProcessorDefinition extends ProcessorDefinition> =
UnionKeysOf<TProcessorDefinition> & ProcessorType;
export const processorDefinitionSchema: z.ZodType<ProcessorDefinition> = z.union([
@ -96,15 +96,20 @@ export const isDissectProcessorDefinition = createIsNarrowSchema(
dissectProcessorDefinitionSchema
);
const processorTypes: ProcessorType[] = (processorDefinitionSchema as z.ZodUnion<any>).options.map(
(option: z.ZodUnion<any>['options'][number]) => Object.keys(option.shape)[0]
);
export function getProcessorType<TProcessorDefinition extends ProcessorDefinition>(
processor: TProcessorDefinition
): ProcessorTypeOf<TProcessorDefinition> {
return Object.keys(processor)[0] as ProcessorTypeOf<TProcessorDefinition>;
return processorTypes.find((type) => type in processor) as ProcessorTypeOf<TProcessorDefinition>;
}
export function getProcessorConfig(processor: ProcessorDefinition): ProcessorConfig {
if ('grok' in processor) {
return processor.grok;
}
return processor.dissect;
export function getProcessorConfig<TProcessorDefinition extends ProcessorDefinition>(
processor: TProcessorDefinition
): ProcessorConfig {
const type = getProcessorType(processor);
return processor[type as keyof TProcessorDefinition];
}

View file

@ -1,94 +0,0 @@
/*
* 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 { z } from '@kbn/zod';
import { NonEmptyString } from '@kbn/zod-helpers';
import {
InheritedFieldDefinition,
UnwiredIngestStreamEffectiveLifecycle,
UnwiredStreamDefinition,
WiredIngestStreamEffectiveLifecycle,
WiredStreamDefinition,
inheritedFieldDefinitionSchema,
unwiredIngestStreamEffectiveLifecycleSchema,
unwiredStreamDefinitionSchema,
wiredIngestStreamEffectiveLifecycleSchema,
wiredStreamDefinitionSchema,
} from './ingest';
import { ElasticsearchAsset, elasticsearchAssetSchema } from './ingest/common';
import { createIsNarrowSchema } from '../helpers';
/**
* These are deprecated types, they should be migrated to the updated types
*/
interface ReadStreamDefinitionBase {
name: string;
dashboards: string[];
elasticsearch_assets: ElasticsearchAsset[];
inherited_fields: InheritedFieldDefinition;
}
interface WiredReadStreamDefinition extends ReadStreamDefinitionBase {
stream: WiredStreamDefinition;
effective_lifecycle: WiredIngestStreamEffectiveLifecycle;
}
interface UnwiredReadStreamDefinition extends ReadStreamDefinitionBase {
stream: UnwiredStreamDefinition;
data_stream_exists: boolean;
effective_lifecycle: UnwiredIngestStreamEffectiveLifecycle;
}
type ReadStreamDefinition = WiredReadStreamDefinition | UnwiredReadStreamDefinition;
const readStreamDefinitionSchemaBase: z.Schema<ReadStreamDefinitionBase> = z.object({
name: z.string(),
dashboards: z.array(NonEmptyString),
elasticsearch_assets: z.array(elasticsearchAssetSchema),
inherited_fields: inheritedFieldDefinitionSchema,
});
const wiredReadStreamDefinitionSchema: z.Schema<WiredReadStreamDefinition> = z.intersection(
readStreamDefinitionSchemaBase,
z.object({
stream: wiredStreamDefinitionSchema,
effective_lifecycle: wiredIngestStreamEffectiveLifecycleSchema,
})
);
const unwiredReadStreamDefinitionSchema: z.Schema<UnwiredReadStreamDefinition> = z.intersection(
readStreamDefinitionSchemaBase,
z.object({
stream: unwiredStreamDefinitionSchema,
data_stream_exists: z.boolean(),
effective_lifecycle: unwiredIngestStreamEffectiveLifecycleSchema,
})
);
const readStreamSchema: z.Schema<ReadStreamDefinition> = z.union([
wiredReadStreamDefinitionSchema,
unwiredReadStreamDefinitionSchema,
]);
const isReadStream = createIsNarrowSchema(z.unknown(), readStreamSchema);
const isWiredReadStream = createIsNarrowSchema(readStreamSchema, wiredReadStreamDefinitionSchema);
const isUnwiredReadStream = createIsNarrowSchema(
readStreamSchema,
unwiredReadStreamDefinitionSchema
);
export {
readStreamSchema,
type ReadStreamDefinition,
type WiredReadStreamDefinition,
type UnwiredReadStreamDefinition,
isReadStream,
isWiredReadStream,
isUnwiredReadStream,
wiredReadStreamDefinitionSchema,
};

View file

@ -28,25 +28,32 @@ import React, { useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/css';
import { CodeEditor } from '@kbn/code-editor';
import { EMPTY_EQUALS_CONDITION } from '../../util/condition';
import {
EMPTY_EQUALS_CONDITION,
alwaysToEmptyEquals,
emptyEqualsToAlways,
} from '../../util/condition';
export function ConditionEditor(props: {
condition: Condition;
readonly?: boolean;
onConditionChange?: (condition: Condition) => void;
}) {
const normalizedCondition = alwaysToEmptyEquals(props.condition);
const handleConditionChange = (condition: Condition) => {
props.onConditionChange?.(emptyEqualsToAlways(condition));
};
if (props.readonly) {
return (
<EuiPanel color="subdued" borderRadius="none" hasShadow={false} paddingSize="xs">
<ConditionDisplay condition={props.condition} />
<ConditionDisplay condition={normalizedCondition} />
</EuiPanel>
);
}
return (
<ConditionForm
condition={props.condition}
onConditionChange={props.onConditionChange || (() => {})}
/>
<ConditionForm condition={normalizedCondition} onConditionChange={handleConditionChange} />
);
}

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { EuiBottomBar, EuiButton, EuiButtonEmpty, EuiFlexGroup } from '@elastic/eui';
import { EuiButton, EuiButtonEmpty, EuiFlexGroup } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useDiscardConfirm } from '../../hooks/use_discard_confirm';
@ -25,37 +25,40 @@ export function ManagementBottomBar({
onCancel,
onConfirm,
}: ManagementBottomBarProps) {
const handleCancel = useDiscardConfirm(onCancel);
const handleCancel = useDiscardConfirm(onCancel, {
title: discardUnsavedChangesTitle,
message: discardUnsavedChangesMessage,
confirmButtonText: discardUnsavedChangesLabel,
cancelButtonText: keepEditingLabel,
});
return (
<EuiBottomBar>
<EuiFlexGroup justifyContent="flexEnd" alignItems="center" responsive={false} gutterSize="s">
<EuiButtonEmpty
data-test-subj="streamsAppManagementBottomBarCancelChangesButton"
color="text"
size="s"
iconType="cross"
onClick={handleCancel}
>
{i18n.translate('xpack.streams.streamDetailView.managementTab.bottomBar.cancel', {
defaultMessage: 'Cancel changes',
})}
</EuiButtonEmpty>
<EuiButton
data-test-subj="streamsAppManagementBottomBarButton"
disabled={disabled}
color="primary"
fill
size="s"
iconType="check"
onClick={onConfirm}
isLoading={isLoading}
>
{confirmButtonText}
</EuiButton>
</EuiFlexGroup>
</EuiBottomBar>
<EuiFlexGroup justifyContent="flexEnd" alignItems="center" responsive={false} gutterSize="s">
<EuiButtonEmpty
data-test-subj="streamsAppManagementBottomBarCancelChangesButton"
color="text"
size="s"
iconType="cross"
onClick={handleCancel}
disabled={disabled}
>
{i18n.translate('xpack.streams.streamDetailView.managementTab.bottomBar.cancel', {
defaultMessage: 'Cancel changes',
})}
</EuiButtonEmpty>
<EuiButton
data-test-subj="streamsAppManagementBottomBarButton"
disabled={disabled}
color="primary"
fill
size="s"
iconType="check"
onClick={onConfirm}
isLoading={isLoading}
>
{confirmButtonText}
</EuiButton>
</EuiFlexGroup>
);
}
@ -63,3 +66,26 @@ const defaultConfirmButtonText = i18n.translate(
'xpack.streams.streamDetailView.managementTab.bottomBar.confirm',
{ defaultMessage: 'Save changes' }
);
const discardUnsavedChangesLabel = i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.discardUnsavedChangesLabel',
{ defaultMessage: 'Discard unsaved changes' }
);
const keepEditingLabel = i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.discardUnsavedChangesKeepEditing',
{ defaultMessage: 'Keep editing' }
);
const discardUnsavedChangesTitle = i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.discardUnsavedChangesTitle',
{ defaultMessage: 'Unsaved changes' }
);
const discardUnsavedChangesMessage = i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.discardUnsavedChangesMessage',
{
defaultMessage:
'You are about to leave this view without saving. All changes will be lost. Do you really want to leave without saving?',
}
);

View file

@ -6,28 +6,18 @@
*/
import { EuiDataGrid } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { CSSProperties, useEffect, useMemo, useState } from 'react';
import { isEmpty } from 'lodash';
import React, { useMemo } from 'react';
export function PreviewTable({
documents,
displayColumns,
height,
}: {
documents: unknown[];
displayColumns?: string[];
height?: CSSProperties['height'];
}) {
const [computedHeight, setComputedHeight] = useState('100px');
useEffect(() => {
// set height to 100% after a short delay otherwise it doesn't calculate correctly
// TODO: figure out a better way to do this
setTimeout(() => {
setComputedHeight(`100%`);
}, 50);
}, []);
const columns = useMemo(() => {
if (displayColumns) return displayColumns;
if (displayColumns && !isEmpty(displayColumns)) return displayColumns;
const cols = new Set<string>();
documents.forEach((doc) => {
@ -42,9 +32,10 @@ export function PreviewTable({
}, [displayColumns, documents]);
const gridColumns = useMemo(() => {
return Array.from(columns).map((column) => ({
return columns.map((column) => ({
id: column,
displayAsText: column,
initialWidth: columns.length > 10 ? 250 : undefined,
}));
}, [columns]);
@ -61,7 +52,6 @@ export function PreviewTable({
}}
toolbarVisibility={false}
rowCount={documents.length}
height={height ?? computedHeight}
renderCellValue={({ rowIndex, columnId }) => {
const doc = documents[rowIndex];
if (!doc || typeof doc !== 'object') {

View file

@ -1,26 +0,0 @@
/*
* 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 from 'react';
import { EuiButton } from '@elastic/eui';
import { EuiButtonPropsForButton } from '@elastic/eui/src/components/button/button';
import { i18n } from '@kbn/i18n';
export function AddProcessorButton(props: EuiButtonPropsForButton) {
return (
<EuiButton
data-test-subj="streamsAppAddProcessorButtonAddAProcessorButton"
iconType="plusInCircle"
{...props}
>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichmentEmptyPrompt.addProcessorAction',
{ defaultMessage: 'Add a processor' }
)}
</EuiButton>
);
}

View file

@ -1,44 +0,0 @@
/*
* 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 from 'react';
import { EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AssetImage } from '../asset_image';
import { AddProcessorButton } from './add_processor_button';
interface EnrichmentEmptyPromptProps {
onAddProcessor: () => void;
}
export const EnrichmentEmptyPrompt = ({ onAddProcessor }: EnrichmentEmptyPromptProps) => {
return (
<EuiEmptyPrompt
titleSize="xs"
icon={<AssetImage />}
title={title}
body={body}
actions={[<AddProcessorButton onClick={onAddProcessor} />]}
/>
);
};
const title = (
<h2>
{i18n.translate('xpack.streams.streamDetailView.managementTab.enrichmentEmptyPrompt.title', {
defaultMessage: 'Start extracting useful fields from your data',
})}
</h2>
);
const body = (
<p>
{i18n.translate('xpack.streams.streamDetailView.managementTab.enrichmentEmptyPrompt.body', {
defaultMessage: 'Use processors to transform data before indexing',
})}
</p>
);

View file

@ -1,93 +0,0 @@
/*
* 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 from 'react';
import {
EuiPanel,
EuiTitle,
EuiSpacer,
useGeneratedHtmlId,
EuiButton,
EuiConfirmModal,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useBoolean } from '@kbn/react-hooks';
export const DangerZone = ({
onDeleteProcessor,
}: Pick<DeleteProcessorButtonProps, 'onDeleteProcessor'>) => {
return (
<EuiPanel hasShadow={false} paddingSize="none">
<EuiTitle size="xs">
<h3>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.dangerAreaTitle',
{ defaultMessage: 'Danger area' }
)}
</h3>
</EuiTitle>
<EuiSpacer />
<DeleteProcessorButton onDeleteProcessor={onDeleteProcessor} />
</EuiPanel>
);
};
interface DeleteProcessorButtonProps {
onDeleteProcessor: () => void;
}
const DeleteProcessorButton = ({ onDeleteProcessor }: DeleteProcessorButtonProps) => {
const [isConfirmModalOpen, { on: openConfirmModal, off: closeConfirmModal }] = useBoolean();
const confirmModalId = useGeneratedHtmlId();
return (
<>
<EuiButton
data-test-subj="streamsAppDeleteProcessorButtonDeleteProcessorButton"
color="danger"
onClick={openConfirmModal}
>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.dangerAreaTitle',
{ defaultMessage: 'Delete processor' }
)}
</EuiButton>
{isConfirmModalOpen && (
<EuiConfirmModal
aria-labelledby={confirmModalId}
title={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.deleteProcessorModalTitle',
{ defaultMessage: 'Delete processor' }
)}
titleProps={{ id: confirmModalId }}
onCancel={closeConfirmModal}
onConfirm={onDeleteProcessor}
cancelButtonText={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.deleteProcessorModalCancel',
{ defaultMessage: 'Keep processor' }
)}
confirmButtonText={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.deleteProcessorModalConfirm',
{ defaultMessage: 'Delete processor' }
)}
buttonColor="danger"
defaultFocusedButton="confirm"
>
<p>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.deleteProcessorModalBody',
{
defaultMessage:
'You can still reset this until the changes are confirmed on the processors list.',
}
)}
</p>
</EuiConfirmModal>
)}
</>
);
};

View file

@ -1,204 +0,0 @@
/*
* 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, { useMemo } from 'react';
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
import { EuiCallOut, EuiForm, EuiButton, EuiSpacer, EuiHorizontalRule } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ReadStreamDefinition } from '@kbn/streams-schema';
import { isEqual } from 'lodash';
import { dynamic } from '@kbn/shared-ux-utility';
import { ProcessorTypeSelector } from './processor_type_selector';
import { ProcessorFlyoutTemplate } from './processor_flyout_template';
import {
DetectedField,
EnrichmentUIProcessorDefinition,
ProcessingDefinition,
ProcessorFormState,
} from '../types';
import { DangerZone } from './danger_zone';
import { DissectProcessorForm } from './dissect';
import { GrokProcessorForm } from './grok';
import { convertFormStateToProcessing, getDefaultFormState } from '../utils';
import { useProcessingSimulator } from '../hooks/use_processing_simulator';
const ProcessorOutcomePreview = dynamic(() =>
import(/* webpackChunkName: "management_processor_outcome" */ './processor_outcome_preview').then(
(mod) => ({
default: mod.ProcessorOutcomePreview,
})
)
);
export interface ProcessorFlyoutProps {
definition: ReadStreamDefinition;
onClose: () => void;
}
export interface AddProcessorFlyoutProps extends ProcessorFlyoutProps {
onAddProcessor: (newProcessing: ProcessingDefinition, newFields?: DetectedField[]) => void;
}
export interface EditProcessorFlyoutProps extends ProcessorFlyoutProps {
processor: EnrichmentUIProcessorDefinition;
onDeleteProcessor: (id: string) => void;
onUpdateProcessor: (id: string, processor: EnrichmentUIProcessorDefinition) => void;
}
export function AddProcessorFlyout({
definition,
onAddProcessor,
onClose,
}: AddProcessorFlyoutProps) {
const defaultValues = useMemo(() => getDefaultFormState('grok'), []);
const methods = useForm<ProcessorFormState>({ defaultValues, mode: 'onChange' });
const formFields = methods.watch();
const hasChanges = useMemo(
() => !isEqual(defaultValues, formFields),
[defaultValues, formFields]
);
const { error, isLoading, refreshSamples, simulation, samples, simulate } =
useProcessingSimulator({
definition,
condition: { field: formFields.field, operator: 'exists' },
});
const handleSubmit: SubmitHandler<ProcessorFormState> = async (data) => {
const processingDefinition = convertFormStateToProcessing(data);
simulate(processingDefinition, data.detected_fields).then((responseBody) => {
if (responseBody instanceof Error) return;
onAddProcessor(processingDefinition, data.detected_fields);
onClose();
});
};
return (
<ProcessorFlyoutTemplate
shouldConfirm={hasChanges}
onClose={onClose}
title={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.titleAdd',
{ defaultMessage: 'Add processor' }
)}
confirmButton={
<EuiButton
data-test-subj="streamsAppAddProcessorFlyoutAddProcessorButton"
onClick={methods.handleSubmit(handleSubmit)}
disabled={!methods.formState.isValid && methods.formState.isSubmitted}
>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.confirmAddProcessor',
{ defaultMessage: 'Add processor' }
)}
</EuiButton>
}
>
<FormProvider {...methods}>
<EuiForm component="form" fullWidth onSubmit={methods.handleSubmit(handleSubmit)}>
<ProcessorTypeSelector />
<EuiSpacer size="m" />
{formFields.type === 'grok' && <GrokProcessorForm />}
{formFields.type === 'dissect' && <DissectProcessorForm />}
</EuiForm>
<EuiHorizontalRule />
<ProcessorOutcomePreview
definition={definition}
formFields={formFields}
simulation={simulation}
samples={samples}
onSimulate={simulate}
onRefreshSamples={refreshSamples}
simulationError={error}
isLoading={isLoading}
/>
</FormProvider>
</ProcessorFlyoutTemplate>
);
}
export function EditProcessorFlyout({
onClose,
onDeleteProcessor,
onUpdateProcessor,
processor,
}: EditProcessorFlyoutProps) {
const processorType = 'grok' in processor.config ? 'grok' : 'dissect';
const defaultValues = useMemo(
() => getDefaultFormState(processorType, processor),
[processor, processorType]
);
const methods = useForm<ProcessorFormState>({ defaultValues, mode: 'onChange' });
const formFields = methods.watch();
const hasChanges = useMemo(
() => !isEqual(defaultValues, formFields),
[defaultValues, formFields]
);
const handleSubmit: SubmitHandler<ProcessorFormState> = (data) => {
const processingDefinition = convertFormStateToProcessing(data);
onUpdateProcessor(processor.id, { id: processor.id, ...processingDefinition });
onClose();
};
const handleProcessorDelete = () => {
onDeleteProcessor(processor.id);
onClose();
};
return (
<ProcessorFlyoutTemplate
shouldConfirm={hasChanges}
onClose={onClose}
title={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.titleEdit',
{ defaultMessage: 'Edit processor' }
)}
banner={
<EuiCallOut
title={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.calloutEdit',
{ defaultMessage: 'Outcome preview is not available during edition' }
)}
iconType="iInCircle"
/>
}
confirmButton={
<EuiButton
data-test-subj="streamsAppEditProcessorFlyoutUpdateProcessorButton"
onClick={methods.handleSubmit(handleSubmit)}
disabled={!methods.formState.isValid}
>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.confirmEditProcessor',
{ defaultMessage: 'Update processor' }
)}
</EuiButton>
}
>
<FormProvider {...methods}>
<EuiForm component="form" fullWidth onSubmit={methods.handleSubmit(handleSubmit)}>
<ProcessorTypeSelector disabled />
<EuiSpacer size="m" />
{formFields.type === 'grok' && <GrokProcessorForm />}
{formFields.type === 'dissect' && <DissectProcessorForm />}
<EuiHorizontalRule />
<DangerZone onDeleteProcessor={handleProcessorDelete} />
</EuiForm>
</FormProvider>
</ProcessorFlyoutTemplate>
);
}

View file

@ -1,66 +0,0 @@
/*
* 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, { PropsWithChildren } from 'react';
import {
EuiFlyoutResizable,
EuiFlyoutHeader,
EuiTitle,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlexGroup,
EuiButtonEmpty,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useDiscardConfirm } from '../../../hooks/use_discard_confirm';
interface ProcessorFlyoutTemplateProps {
banner?: React.ReactNode;
confirmButton?: React.ReactNode;
onClose: () => void;
shouldConfirm?: boolean;
title: string;
}
export function ProcessorFlyoutTemplate({
banner,
children,
confirmButton,
onClose,
shouldConfirm = false,
title,
}: PropsWithChildren<ProcessorFlyoutTemplateProps>) {
const handleClose = useDiscardConfirm(onClose);
const closeHandler = shouldConfirm ? handleClose : onClose;
return (
<EuiFlyoutResizable onClose={closeHandler}>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>{title}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody banner={banner}>{children}</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiButtonEmpty
data-test-subj="streamsAppProcessorFlyoutTemplateCancelButton"
iconType="cross"
onClick={closeHandler}
>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.cancel',
{ defaultMessage: 'Cancel' }
)}
</EuiButtonEmpty>
{confirmButton}
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyoutResizable>
);
}

View file

@ -1,416 +0,0 @@
/*
* 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, { useEffect, useMemo, useState } from 'react';
import { useDateRange } from '@kbn/observability-utils-browser/hooks/use_date_range';
import {
EuiPanel,
EuiTitle,
EuiSpacer,
EuiFlexGroup,
EuiFilterButton,
EuiFilterGroup,
EuiEmptyPrompt,
EuiLoadingLogo,
EuiButton,
EuiFormRow,
EuiSuperSelectOption,
EuiSuperSelect,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { TimeRange } from '@kbn/es-query';
import { isEmpty } from 'lodash';
import { FieldIcon } from '@kbn/react-field';
import {
FIELD_DEFINITION_TYPES,
ReadStreamDefinition,
isWiredReadStream,
} from '@kbn/streams-schema';
import { UseControllerProps, useController, useFieldArray } from 'react-hook-form';
import { css } from '@emotion/react';
import { flattenObject } from '@kbn/object-utils';
import { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public';
import { useKibana } from '../../../hooks/use_kibana';
import { StreamsAppSearchBar, StreamsAppSearchBarProps } from '../../streams_app_search_bar';
import { PreviewTable } from '../../preview_table';
import { convertFormStateToProcessing } from '../utils';
import { DetectedField, ProcessorFormState } from '../types';
import { UseProcessingSimulatorReturnType } from '../hooks/use_processing_simulator';
interface ProcessorOutcomePreviewProps {
definition: ReadStreamDefinition;
formFields: ProcessorFormState;
isLoading: UseProcessingSimulatorReturnType['isLoading'];
simulation: UseProcessingSimulatorReturnType['simulation'];
samples: UseProcessingSimulatorReturnType['samples'];
onRefreshSamples: UseProcessingSimulatorReturnType['refreshSamples'];
onSimulate: UseProcessingSimulatorReturnType['simulate'];
simulationError: UseProcessingSimulatorReturnType['error'];
}
export const ProcessorOutcomePreview = ({
definition,
formFields,
isLoading,
simulation,
samples,
onRefreshSamples,
onSimulate,
simulationError,
}: ProcessorOutcomePreviewProps) => {
const { dependencies } = useKibana();
const { data } = dependencies.start;
const { timeRange, setTimeRange } = useDateRange({ data });
const [selectedDocsFilter, setSelectedDocsFilter] =
useState<DocsFilterOption>('outcome_filter_all');
const simulationDocuments = useMemo(() => {
if (!simulation?.documents) {
return samples.map((doc) => flattenObject(doc));
}
const filterDocuments = (filter: DocsFilterOption) => {
switch (filter) {
case 'outcome_filter_matched':
return simulation.documents.filter((doc) => doc.isMatch);
case 'outcome_filter_unmatched':
return simulation.documents.filter((doc) => !doc.isMatch);
case 'outcome_filter_all':
default:
return simulation.documents;
}
};
return filterDocuments(selectedDocsFilter).map((doc) => doc.value);
}, [samples, simulation?.documents, selectedDocsFilter]);
const detectedFieldsColumns = useMemo(
() =>
simulation?.detected_fields ? simulation.detected_fields.map((field) => field.name) : [],
[simulation?.detected_fields]
);
const tableColumns = useMemo(() => {
switch (selectedDocsFilter) {
case 'outcome_filter_unmatched':
return [formFields.field];
case 'outcome_filter_matched':
case 'outcome_filter_all':
default:
return [formFields.field, ...detectedFieldsColumns];
}
}, [formFields.field, detectedFieldsColumns, selectedDocsFilter]);
const detectedFieldsEnabled =
isWiredReadStream(definition) &&
((simulation && !isEmpty(simulation.detected_fields)) || !isEmpty(formFields.detected_fields));
return (
<EuiPanel hasShadow={false} paddingSize="none">
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiTitle size="xs">
<h3>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.outcomeTitle',
{ defaultMessage: 'Outcome' }
)}
</h3>
</EuiTitle>
<EuiButton
data-test-subj="streamsAppProcessorOutcomePreviewRunSimulationButton"
iconType="play"
color="accentSecondary"
size="s"
onClick={() => {
onSimulate(convertFormStateToProcessing(formFields), formFields.detected_fields);
}}
isLoading={isLoading}
>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.runSimulation',
{ defaultMessage: 'Run simulation' }
)}
</EuiButton>
</EuiFlexGroup>
<EuiSpacer />
{detectedFieldsEnabled && <DetectedFields detectedFields={simulation?.detected_fields} />}
<OutcomeControls
docsFilter={selectedDocsFilter}
onDocsFilterChange={setSelectedDocsFilter}
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
onTimeRangeRefresh={onRefreshSamples}
simulationFailureRate={simulation?.failure_rate}
simulationSuccessRate={simulation?.success_rate}
/>
<EuiSpacer size="m" />
<OutcomePreviewTable
documents={simulationDocuments}
columns={tableColumns}
error={simulationError}
isLoading={isLoading}
/>
</EuiPanel>
);
};
const docsFilterOptions = {
outcome_filter_all: {
id: 'outcome_filter_all',
label: i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.outcomeControls.all',
{ defaultMessage: 'All samples' }
),
},
outcome_filter_matched: {
id: 'outcome_filter_matched',
label: i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.outcomeControls.matched',
{ defaultMessage: 'Matched' }
),
},
outcome_filter_unmatched: {
id: 'outcome_filter_unmatched',
label: i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.outcomeControls.unmatched',
{ defaultMessage: 'Unmatched' }
),
},
} as const;
type DocsFilterOption = keyof typeof docsFilterOptions;
interface OutcomeControlsProps {
docsFilter: DocsFilterOption;
timeRange: TimeRange;
onDocsFilterChange: (filter: DocsFilterOption) => void;
onTimeRangeChange: (timeRange: TimeRange) => void;
onTimeRangeRefresh: () => void;
simulationFailureRate?: number;
simulationSuccessRate?: number;
}
const OutcomeControls = ({
docsFilter,
timeRange,
onDocsFilterChange,
onTimeRangeChange,
onTimeRangeRefresh,
simulationFailureRate,
simulationSuccessRate,
}: OutcomeControlsProps) => {
const handleQuerySubmit: StreamsAppSearchBarProps['onQuerySubmit'] = (
{ dateRange },
isUpdate
) => {
if (!isUpdate) {
return onTimeRangeRefresh();
}
if (dateRange) {
onTimeRangeChange({
from: dateRange.from,
to: dateRange?.to,
mode: dateRange.mode,
});
}
};
return (
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" wrap>
<EuiFilterGroup
aria-label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.outcomeControlsAriaLabel',
{ defaultMessage: 'Filter for all, matching or unmatching previewed documents.' }
)}
>
<EuiFilterButton
hasActiveFilters={docsFilter === docsFilterOptions.outcome_filter_all.id}
onClick={() => onDocsFilterChange(docsFilterOptions.outcome_filter_all.id)}
>
{docsFilterOptions.outcome_filter_all.label}
</EuiFilterButton>
<EuiFilterButton
hasActiveFilters={docsFilter === docsFilterOptions.outcome_filter_matched.id}
onClick={() => onDocsFilterChange(docsFilterOptions.outcome_filter_matched.id)}
badgeColor="success"
numActiveFilters={
simulationSuccessRate ? parseFloat((simulationSuccessRate * 100).toFixed(2)) : undefined
}
>
{docsFilterOptions.outcome_filter_matched.label}
</EuiFilterButton>
<EuiFilterButton
hasActiveFilters={docsFilter === docsFilterOptions.outcome_filter_unmatched.id}
onClick={() => onDocsFilterChange(docsFilterOptions.outcome_filter_unmatched.id)}
badgeColor="accent"
numActiveFilters={
simulationFailureRate ? parseFloat((simulationFailureRate * 100).toFixed(2)) : undefined
}
>
{docsFilterOptions.outcome_filter_unmatched.label}
</EuiFilterButton>
</EuiFilterGroup>
<StreamsAppSearchBar
onQuerySubmit={handleQuerySubmit}
onRefresh={onTimeRangeRefresh}
dateRangeFrom={timeRange.from}
dateRangeTo={timeRange.to}
/>
</EuiFlexGroup>
);
};
const DetectedFields = ({ detectedFields }: { detectedFields?: DetectedField[] }) => {
const { euiTheme } = useEuiTheme();
const { fields, replace } = useFieldArray<{ detected_fields: DetectedField[] }>({
name: 'detected_fields',
});
useEffect(() => {
if (detectedFields) replace(detectedFields);
}, [detectedFields, replace]);
return (
<EuiFormRow
label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.detectedFieldsLabel',
{ defaultMessage: 'Detected fields' }
)}
css={css`
margin-bottom: ${euiTheme.size.l};
`}
fullWidth
>
<EuiFlexGroup gutterSize="s" wrap>
{fields.map((field, id) => (
<DetectedFieldSelector key={field.name} name={`detected_fields.${id}`} />
))}
</EuiFlexGroup>
</EuiFormRow>
);
};
const DetectedFieldSelector = (
props: UseControllerProps<ProcessorFormState, `detected_fields.${number}`>
) => {
const { field } = useController(props);
const options = useMemo(() => getDetectedFieldSelectOptions(field.value), [field.value]);
return (
<EuiSuperSelect
options={options}
valueOfSelected={field.value.type}
onChange={(type) => field.onChange({ ...field.value, type })}
css={css`
min-inline-size: 180px;
`}
/>
);
};
const getDetectedFieldSelectOptions = (
fieldValue: DetectedField
): Array<EuiSuperSelectOption<string>> =>
[...FIELD_DEFINITION_TYPES, 'unmapped'].map((type) => ({
value: type,
inputDisplay: (
<EuiFlexGroup alignItems="center" gutterSize="s">
<FieldIcon type={fieldValue.type} size="s" />
{fieldValue.name}
</EuiFlexGroup>
),
dropdownDisplay: (
<EuiFlexGroup alignItems="center" gutterSize="s">
<FieldIcon type={type} size="s" />
{type}
</EuiFlexGroup>
),
}));
interface OutcomePreviewTableProps {
documents?: Array<Record<PropertyKey, unknown>>;
columns: string[];
error?: IHttpFetchError<ResponseErrorBody>;
isLoading?: boolean;
}
const OutcomePreviewTable = ({
documents = [],
columns,
error,
isLoading,
}: OutcomePreviewTableProps) => {
if (error) {
return (
<EuiEmptyPrompt
iconType="error"
color="danger"
title={
<h3>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.outcomePreviewTable.errorTitle',
{ defaultMessage: 'Unable to display the simulation outcome for this processor.' }
)}
</h3>
}
body={
<>
<p>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.outcomePreviewTable.errorBody',
{ defaultMessage: 'The processor did not run correctly.' }
)}
</p>
{error.body?.message ? <p>{error.body.message}</p> : null}
</>
}
/>
);
}
if (isLoading) {
return (
<EuiEmptyPrompt
icon={<EuiLoadingLogo logo="logoLogging" size="l" />}
title={
<h3>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.outcomePreviewTable.loadingTitle',
{ defaultMessage: 'Running processor simulation' }
)}
</h3>
}
/>
);
}
if (documents?.length === 0) {
return (
<EuiEmptyPrompt
iconType="dataVisualizer"
body={
<p>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.outcomePreviewTable.noDataTitle',
{
defaultMessage:
'There are no simulation outcome documents for the current selection.',
}
)}
</p>
}
/>
);
}
return <PreviewTable documents={documents} displayColumns={columns} height={500} />;
};

View file

@ -5,26 +5,44 @@
* 2.0.
*/
import { useState, useMemo, useEffect } from 'react';
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { useAbortController } from '@kbn/observability-utils-browser/hooks/use_abort_controller';
import { useBoolean } from '@kbn/react-hooks';
import {
ReadStreamDefinition,
isWiredReadStream,
IngestStreamGetResponse,
isWiredStreamGetResponse,
FieldDefinition,
WiredReadStreamDefinition,
ProcessorDefinition,
getProcessorConfig,
WiredStreamGetResponse,
IngestUpsertRequest,
ProcessorDefinition,
getProcessorType,
} from '@kbn/streams-schema';
import { htmlIdGenerator } from '@elastic/eui';
import { isEqual, omit } from 'lodash';
import { DetectedField, EnrichmentUIProcessorDefinition, ProcessingDefinition } from '../types';
import { DetectedField, ProcessorDefinitionWithUIAttributes } from '../types';
import { useKibana } from '../../../hooks/use_kibana';
import { alwaysToEmptyEquals, emptyEqualsToAlways } from '../../../util/condition';
import { processorConverter } from '../utils';
export const useDefinition = (definition: ReadStreamDefinition, refreshDefinition: () => void) => {
export interface UseDefinitionReturn {
processors: ProcessorDefinitionWithUIAttributes[];
hasChanges: boolean;
isSavingChanges: boolean;
addProcessor: (newProcessor: ProcessorDefinition, newFields?: DetectedField[]) => void;
updateProcessor: (
id: string,
processor: ProcessorDefinition,
status?: ProcessorDefinitionWithUIAttributes['status']
) => void;
deleteProcessor: (id: string) => void;
reorderProcessors: (processors: ProcessorDefinitionWithUIAttributes[]) => void;
saveChanges: () => Promise<void>;
setProcessors: (processors: ProcessorDefinitionWithUIAttributes[]) => void;
resetChanges: () => void;
}
export const useDefinition = (
definition: IngestStreamGetResponse,
refreshDefinition: () => void
): UseDefinitionReturn => {
const { core, dependencies } = useKibana();
const { toasts } = core.notifications;
@ -37,46 +55,79 @@ export const useDefinition = (definition: ReadStreamDefinition, refreshDefinitio
const [processors, setProcessors] = useState(() =>
createProcessorsList(existingProcessorDefinitions)
);
const initialProcessors = useRef(processors);
const [fields, setFields] = useState(() =>
isWiredReadStream(definition) ? definition.stream.ingest.wired.fields : {}
isWiredStreamGetResponse(definition) ? definition.stream.ingest.wired.fields : {}
);
const nextProcessorDefinitions = useMemo(
() => processors.map(convertUiDefinitionIntoApiDefinition),
() => processors.map(processorConverter.toAPIDefinition),
[processors]
);
useEffect(() => {
// Reset processors when definition refreshes
setProcessors(createProcessorsList(definition.stream.ingest.processing));
const resetProcessors = createProcessorsList(definition.stream.ingest.processing);
setProcessors(resetProcessors);
initialProcessors.current = resetProcessors;
}, [definition]);
const hasChanges = useMemo(
() => !isEqual(existingProcessorDefinitions, nextProcessorDefinitions),
[existingProcessorDefinitions, nextProcessorDefinitions]
() =>
processors.length !== initialProcessors.current.length || // Processor count changed, a processor might be deleted
processors.some((proc) => proc.status === 'draft' || proc.status === 'updated') || // New or updated processors
hasOrderChanged(processors, initialProcessors.current), // Processor order changed
[processors]
);
const addProcessor = (newProcessing: ProcessingDefinition, newFields?: DetectedField[]) => {
setProcessors((prevProcs) => prevProcs.concat({ ...newProcessing, id: createId() }));
const addProcessor = useCallback(
(newProcessor: ProcessorDefinition, newFields?: DetectedField[]) => {
setProcessors((prevProcs) =>
prevProcs.concat(processorConverter.toUIDefinition(newProcessor, { status: 'draft' }))
);
if (isWiredReadStream(definition) && newFields) {
setFields((currentFields) => mergeFields(definition, currentFields, newFields));
}
};
if (isWiredStreamGetResponse(definition) && newFields) {
setFields((currentFields) => mergeFields(definition, currentFields, newFields));
}
},
[definition]
);
const updateProcessor = (id: string, processorUpdate: EnrichmentUIProcessorDefinition) => {
setProcessors((prevProcs) =>
prevProcs.map((proc) => (proc.id === id ? processorUpdate : proc))
);
};
const updateProcessor = useCallback(
(
id: string,
processorUpdate: ProcessorDefinition,
status: ProcessorDefinitionWithUIAttributes['status'] = 'updated'
) => {
setProcessors((prevProcs) =>
prevProcs.map((proc) =>
proc.id === id
? {
...processorUpdate,
id,
type: getProcessorType(processorUpdate),
status,
}
: proc
)
);
},
[]
);
const deleteProcessor = (id: string) => {
const reorderProcessors = setProcessors;
const deleteProcessor = useCallback((id: string) => {
setProcessors((prevProcs) => prevProcs.filter((proc) => proc.id !== id));
};
}, []);
const resetChanges = () => {
setProcessors(createProcessorsList(existingProcessorDefinitions));
setFields(isWiredReadStream(definition) ? definition.stream.ingest.wired.fields : {});
const resetProcessors = createProcessorsList(existingProcessorDefinitions);
setProcessors(resetProcessors);
initialProcessors.current = resetProcessors;
setFields(isWiredStreamGetResponse(definition) ? definition.stream.ingest.wired.fields : {});
};
const saveChanges = async () => {
@ -86,13 +137,13 @@ export const useDefinition = (definition: ReadStreamDefinition, refreshDefinitio
signal: abortController.signal,
params: {
path: {
id: definition.name,
id: definition.stream.name,
},
body: {
ingest: {
...definition.stream.ingest,
processing: nextProcessorDefinitions,
...(isWiredReadStream(definition) && { wired: { fields } }),
...(isWiredStreamGetResponse(definition) && { wired: { fields } }),
},
} as IngestUpsertRequest,
},
@ -104,8 +155,10 @@ export const useDefinition = (definition: ReadStreamDefinition, refreshDefinitio
{ defaultMessage: "Stream's processors updated" }
)
);
refreshDefinition();
} catch (error) {
toasts.addError(error, {
toasts.addError(new Error(error.body.message), {
title: i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.saveChangesError',
{ defaultMessage: "An issue occurred saving processors' changes." }
@ -113,7 +166,6 @@ export const useDefinition = (definition: ReadStreamDefinition, refreshDefinitio
toastMessage: error.body.message,
});
} finally {
await refreshDefinition();
endsSaving();
}
};
@ -125,6 +177,7 @@ export const useDefinition = (definition: ReadStreamDefinition, refreshDefinitio
addProcessor,
updateProcessor,
deleteProcessor,
reorderProcessors,
resetChanges,
saveChanges,
setProcessors,
@ -134,46 +187,19 @@ export const useDefinition = (definition: ReadStreamDefinition, refreshDefinitio
};
};
const createId = htmlIdGenerator();
const createProcessorsList = (
processors: ProcessorDefinition[]
): EnrichmentUIProcessorDefinition[] => processors.map(createProcessorWithId);
const createProcessorsList = (processors: ProcessorDefinition[]) => {
return processors.map((processor) => processorConverter.toUIDefinition(processor));
};
const createProcessorWithId = (
processor: ProcessorDefinition
): EnrichmentUIProcessorDefinition => ({
condition: alwaysToEmptyEquals(getProcessorConfig(processor).if),
config: {
...('grok' in processor
? { grok: omit(processor.grok, 'if') }
: { dissect: omit(processor.dissect, 'if') }),
},
id: createId(),
});
const convertUiDefinitionIntoApiDefinition = (
processor: EnrichmentUIProcessorDefinition
): ProcessorDefinition => {
const { id: _id, config, condition } = processor;
if ('grok' in config) {
return {
grok: {
...config.grok,
if: emptyEqualsToAlways(condition),
},
};
}
return {
dissect: {
...config.dissect,
if: emptyEqualsToAlways(condition),
},
};
const hasOrderChanged = (
processors: ProcessorDefinitionWithUIAttributes[],
initialProcessors: ProcessorDefinitionWithUIAttributes[]
) => {
return processors.some((processor, index) => processor.id !== initialProcessors[index].id);
};
const mergeFields = (
definition: WiredReadStreamDefinition,
definition: WiredStreamGetResponse,
currentFields: FieldDefinition,
newFields: DetectedField[]
) => {

View file

@ -5,49 +5,53 @@
* 2.0.
*/
/* eslint-disable @typescript-eslint/naming-convention */
import { useAbortController } from '@kbn/observability-utils-browser/hooks/use_abort_controller';
import { useEffect, useMemo, useState } from 'react';
import { debounce, isEmpty, uniq, uniqBy } from 'lodash';
import {
DissectProcessorDefinition,
ReadStreamDefinition,
IngestStreamGetResponse,
getProcessorConfig,
UnaryOperator,
Condition,
ProcessorDefinition,
GrokProcessorDefinition,
processorDefinitionSchema,
isSchema,
} from '@kbn/streams-schema';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public';
import { useDateRange } from '@kbn/observability-utils-browser/hooks/use_date_range';
import { APIReturnType, StreamsAPIClientRequestParamsOf } from '@kbn/streams-plugin/public/api';
import { APIReturnType } from '@kbn/streams-plugin/public/api';
import { useStreamsAppFetch } from '../../../hooks/use_streams_app_fetch';
import { useKibana } from '../../../hooks/use_kibana';
import { ProcessingDefinition } from '../types';
import { DetectedField } from '../types';
import { emptyEqualsToAlways } from '../../../util/condition';
import { DetectedField, ProcessorDefinitionWithUIAttributes } from '../types';
import { processorConverter } from '../utils';
type Simulation = APIReturnType<'POST /api/streams/{id}/processing/_simulate'>;
type SimulationRequestBody =
StreamsAPIClientRequestParamsOf<'POST /api/streams/{id}/processing/_simulate'>['params']['body'];
export interface UseProcessingSimulatorReturnType {
export interface TableColumn {
name: string;
origin: 'processor' | 'detected';
}
export interface UseProcessingSimulatorProps {
definition: IngestStreamGetResponse;
processors: ProcessorDefinitionWithUIAttributes[];
}
export interface UseProcessingSimulatorReturn {
hasLiveChanges: boolean;
error?: IHttpFetchError<ResponseErrorBody>;
isLoading: boolean;
refreshSamples: () => void;
samples: Array<Record<PropertyKey, unknown>>;
simulate: (
processing: ProcessingDefinition,
detectedFields?: DetectedField[]
) => Promise<Simulation | null>;
simulation?: Simulation | null;
tableColumns: TableColumn[];
refreshSamples: () => void;
watchProcessor: (
processor: ProcessorDefinitionWithUIAttributes | { id: string; deleteIfExists: true }
) => void;
}
export const useProcessingSimulator = ({
definition,
condition,
}: {
definition: ReadStreamDefinition;
condition?: Condition;
}): UseProcessingSimulatorReturnType => {
processors,
}: UseProcessingSimulatorProps): UseProcessingSimulatorReturn => {
const { dependencies } = useKibana();
const {
data,
@ -58,9 +62,57 @@ export const useProcessingSimulator = ({
absoluteTimeRange: { start, end },
} = useDateRange({ data });
const abortController = useAbortController();
const draftProcessors = useMemo(
() => processors.filter((processor) => processor.status === 'draft'),
[processors]
);
const serializedCondition = JSON.stringify(condition);
const [liveDraftProcessors, setLiveDraftProcessors] = useState(draftProcessors);
useEffect(() => {
setLiveDraftProcessors((prevLiveProcessors) => {
const inProgressDraft = prevLiveProcessors.find((proc) => proc.id === 'draft');
return inProgressDraft ? [...draftProcessors, inProgressDraft] : draftProcessors;
});
}, [draftProcessors]);
const watchProcessor = useMemo(
() =>
debounce(
(processor: ProcessorDefinitionWithUIAttributes | { id: string; deleteIfExists: true }) => {
if ('deleteIfExists' in processor) {
return setLiveDraftProcessors((prevLiveDraftProcessors) =>
prevLiveDraftProcessors.filter((proc) => proc.id !== processor.id)
);
}
if (processor.status === 'draft') {
setLiveDraftProcessors((prevLiveDraftProcessors) => {
const newLiveDraftProcessors = prevLiveDraftProcessors.slice();
const existingIndex = prevLiveDraftProcessors.findIndex(
(proc) => proc.id === processor.id
);
if (existingIndex !== -1) {
newLiveDraftProcessors[existingIndex] = processor;
} else {
newLiveDraftProcessors.push(processor);
}
return newLiveDraftProcessors;
});
}
},
500
),
[]
);
const samplingCondition = useMemo(
() => composeSamplingCondition(liveDraftProcessors),
[liveDraftProcessors]
);
const {
loading: isLoadingSamples,
@ -75,9 +127,9 @@ export const useProcessingSimulator = ({
return streamsRepositoryClient.fetch('POST /api/streams/{id}/_sample', {
signal,
params: {
path: { id: definition.name },
path: { id: definition.stream.name },
body: {
if: condition ? emptyEqualsToAlways(condition) : { always: {} },
if: samplingCondition,
start: start?.valueOf(),
end: end?.valueOf(),
size: 100,
@ -85,69 +137,103 @@ export const useProcessingSimulator = ({
},
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[definition, streamsRepositoryClient, start, end, serializedCondition],
[definition, streamsRepositoryClient, start, end, samplingCondition],
{ disableToastOnError: true }
);
const sampleDocs = (samples?.documents ?? []) as Array<Record<PropertyKey, unknown>>;
const sampleDocs = samples?.documents as Array<Record<PropertyKey, unknown>>;
const [{ loading: isLoadingSimulation, error, value }, simulate] = useAsyncFn(
(processingDefinition: ProcessingDefinition, detectedFields?: DetectedField[]) => {
if (!definition) {
const {
loading: isLoadingSimulation,
value: simulation,
error: simulationError,
} = useStreamsAppFetch(
({ signal }) => {
if (!definition || isEmpty(sampleDocs) || isEmpty(liveDraftProcessors)) {
return Promise.resolve(null);
}
const processorDefinition: ProcessorDefinition =
'grok' in processingDefinition.config
? ({
grok: {
field: processingDefinition.config.grok.field,
ignore_failure: processingDefinition.config.grok.ignore_failure,
ignore_missing: processingDefinition.config.grok.ignore_missing,
if: emptyEqualsToAlways(processingDefinition.condition),
patterns: processingDefinition.config.grok.patterns,
pattern_definitions: processingDefinition.config.grok.pattern_definitions,
},
} satisfies GrokProcessorDefinition)
: ({
dissect: {
field: processingDefinition.config.dissect.field,
ignore_failure: processingDefinition.config.dissect.ignore_failure,
ignore_missing: processingDefinition.config.dissect.ignore_missing,
if: emptyEqualsToAlways(processingDefinition.condition),
pattern: processingDefinition.config.dissect.pattern,
append_separator: processingDefinition.config.dissect.append_separator,
},
} satisfies DissectProcessorDefinition);
const processing = liveDraftProcessors.map(processorConverter.toAPIDefinition);
const detected_fields = detectedFields
? (detectedFields.filter(
(field) => field.type !== 'unmapped'
) as SimulationRequestBody['detected_fields'])
: undefined;
const hasValidProcessors = processing.every((processor) =>
isSchema(processorDefinitionSchema, processor)
);
// Each processor should meet the minimum schema requirements to run the simulation
if (!hasValidProcessors) {
return Promise.resolve(null);
}
return streamsRepositoryClient.fetch('POST /api/streams/{id}/processing/_simulate', {
signal: abortController.signal,
signal,
params: {
path: { id: definition.name },
path: { id: definition.stream.name },
body: {
documents: sampleDocs,
processing: [processorDefinition],
detected_fields,
processing: liveDraftProcessors.map(processorConverter.toAPIDefinition),
},
},
});
},
[definition, sampleDocs]
[definition, sampleDocs, liveDraftProcessors, streamsRepositoryClient],
{ disableToastOnError: true }
);
const tableColumns = useMemo(() => {
// If there is an error, we only want the source fields
const detectedFields = simulationError ? [] : simulation?.detected_fields ?? [];
return getTableColumns(liveDraftProcessors, detectedFields);
}, [liveDraftProcessors, simulation, simulationError]);
const hasLiveChanges = !isEmpty(liveDraftProcessors);
return {
hasLiveChanges,
isLoading: isLoadingSamples || isLoadingSimulation,
error: error as IHttpFetchError<ResponseErrorBody> | undefined,
error: simulationError as IHttpFetchError<ResponseErrorBody> | undefined,
refreshSamples,
simulate,
simulation: value,
samples: sampleDocs,
simulation,
samples: sampleDocs ?? [],
tableColumns,
watchProcessor,
};
};
const composeSamplingCondition = (
processors: ProcessorDefinitionWithUIAttributes[]
): Condition | undefined => {
if (isEmpty(processors)) {
return undefined;
}
const uniqueFields = uniq(getSourceFields(processors));
const conditions = uniqueFields.map((field) => ({
field,
operator: 'exists' as UnaryOperator,
}));
return { or: conditions };
};
const getSourceFields = (processors: ProcessorDefinitionWithUIAttributes[]): string[] => {
return processors.map((processor) => getProcessorConfig(processor).field);
};
const getTableColumns = (
processors: ProcessorDefinitionWithUIAttributes[],
fields: DetectedField[]
) => {
const uniqueProcessorsFields = getSourceFields(processors).map((name) => ({
name,
origin: 'processor',
}));
const uniqueDetectedFields = fields.map((field) => ({
name: field.name,
origin: 'detected',
}));
return uniqBy([...uniqueProcessorsFields, ...uniqueDetectedFields], 'name') as TableColumn[];
};

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { dynamic } from '@kbn/shared-ux-utility';
import { ReadStreamDefinition } from '@kbn/streams-schema';
import { IngestStreamGetResponse } from '@kbn/streams-schema';
const StreamDetailEnrichmentContent = dynamic(() =>
import(/* webpackChunkName: "management_enrichment" */ './page_content').then((mod) => ({
@ -15,7 +15,7 @@ const StreamDetailEnrichmentContent = dynamic(() =>
);
interface StreamDetailEnrichmentProps {
definition?: ReadStreamDefinition;
definition?: IngestStreamGetResponse;
refreshDefinition: () => void;
}

View file

@ -5,31 +5,40 @@
* 2.0.
*/
import React, { useEffect } from 'react';
import React from 'react';
import {
DragDropContextProps,
EuiPanel,
EuiSpacer,
EuiResizableContainer,
EuiSplitPanel,
EuiText,
EuiTitle,
euiDragDropReorder,
useEuiShadow,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ReadStreamDefinition, isRootStreamDefinition } from '@kbn/streams-schema';
import { useBoolean } from '@kbn/react-hooks';
import { IngestStreamGetResponse, isRootStreamDefinition } from '@kbn/streams-schema';
import { useUnsavedChangesPrompt } from '@kbn/unsaved-changes-prompt';
import { EnrichmentEmptyPrompt } from './enrichment_empty_prompt';
import { AddProcessorButton } from './add_processor_button';
import { AddProcessorFlyout } from './flyout';
import { DraggableProcessorListItem } from './processors_list';
import { ManagementBottomBar } from '../management_bottom_bar';
import { SortableList } from './sortable_list';
import { useDefinition } from './hooks/use_definition';
import { css } from '@emotion/react';
import { isEmpty } from 'lodash';
import { UseDefinitionReturn, useDefinition } from './hooks/use_definition';
import { useKibana } from '../../hooks/use_kibana';
import { RootStreamEmptyPrompt } from './root_stream_empty_prompt';
import { DraggableProcessorListItem } from './processors_list';
import { SortableList } from './sortable_list';
import { ManagementBottomBar } from '../management_bottom_bar';
import { AddProcessorPanel } from './processors';
import { SimulationPlayground } from './simulation_playground';
import {
UseProcessingSimulatorReturn,
useProcessingSimulator,
} from './hooks/use_processing_simulator';
const MemoSimulationPlayground = React.memo(SimulationPlayground);
interface StreamDetailEnrichmentContentProps {
definition: ReadStreamDefinition;
definition: IngestStreamGetResponse;
refreshDefinition: () => void;
}
@ -39,9 +48,6 @@ export function StreamDetailEnrichmentContent({
}: StreamDetailEnrichmentContentProps) {
const { appParams, core } = useKibana();
const [isBottomBarOpen, { on: openBottomBar, off: closeBottomBar }] = useBoolean();
const [isAddProcessorOpen, { on: openAddProcessor, off: closeAddProcessor }] = useBoolean();
const {
processors,
addProcessor,
@ -49,110 +55,196 @@ export function StreamDetailEnrichmentContent({
deleteProcessor,
resetChanges,
saveChanges,
setProcessors,
reorderProcessors,
hasChanges,
isSavingChanges,
} = useDefinition(definition, refreshDefinition);
const handlerItemDrag: DragDropContextProps['onDragEnd'] = ({ source, destination }) => {
if (source && destination) {
const items = euiDragDropReorder(processors, source.index, destination.index);
setProcessors(items);
}
};
useEffect(() => {
if (hasChanges) openBottomBar();
else closeBottomBar();
}, [closeBottomBar, hasChanges, openBottomBar]);
const {
hasLiveChanges,
isLoading,
refreshSamples,
samples,
simulation,
tableColumns,
watchProcessor,
} = useProcessingSimulator({ definition, processors });
useUnsavedChangesPrompt({
hasUnsavedChanges: hasChanges,
hasUnsavedChanges: hasChanges || hasLiveChanges,
history: appParams.history,
http: core.http,
navigateToUrl: core.application.navigateToUrl,
openConfirm: core.overlays.openConfirm,
});
const handleSaveChanges = async () => {
await saveChanges();
closeBottomBar();
};
const handleDiscardChanges = async () => {
await resetChanges();
closeBottomBar();
};
const bottomBar = isBottomBarOpen && (
<ManagementBottomBar
onCancel={handleDiscardChanges}
onConfirm={handleSaveChanges}
isLoading={isSavingChanges}
/>
);
const addProcessorFlyout = isAddProcessorOpen && (
<AddProcessorFlyout
key="add-processor"
definition={definition}
onClose={closeAddProcessor}
onAddProcessor={addProcessor}
/>
);
const hasProcessors = processors.length > 0;
if (isRootStreamDefinition(definition.stream)) {
return <RootStreamEmptyPrompt />;
}
return (
<>
{hasProcessors ? (
<EuiPanel paddingSize="none">
<ProcessorsHeader />
<EuiSpacer size="l" />
<SortableList onDragItem={handlerItemDrag}>
{processors.map((processor, idx) => (
<DraggableProcessorListItem
key={processor.id}
idx={idx}
definition={definition}
processor={processor}
onUpdateProcessor={updateProcessor}
onDeleteProcessor={deleteProcessor}
/>
))}
</SortableList>
<EuiSpacer size="m" />
<AddProcessorButton onClick={openAddProcessor} />
</EuiPanel>
) : (
<EnrichmentEmptyPrompt onAddProcessor={openAddProcessor} />
)}
{addProcessorFlyout}
{bottomBar}
</>
<EuiSplitPanel.Outer grow hasBorder hasShadow={false}>
<EuiSplitPanel.Inner
paddingSize="none"
css={css`
display: flex;
overflow: auto;
`}
>
<EuiResizableContainer>
{(EuiResizablePanel, EuiResizableButton) => (
<>
<EuiResizablePanel
initialSize={25}
minSize="400px"
tabIndex={0}
paddingSize="none"
css={verticalFlexCss}
>
<ProcessorsEditor
definition={definition}
processors={processors}
onUpdateProcessor={updateProcessor}
onDeleteProcessor={deleteProcessor}
onWatchProcessor={watchProcessor}
onAddProcessor={addProcessor}
onReorderProcessor={reorderProcessors}
/>
</EuiResizablePanel>
<EuiResizableButton indicator="border" accountForScrollbars="both" />
<EuiResizablePanel
initialSize={75}
minSize="300px"
tabIndex={0}
paddingSize="s"
css={verticalFlexCss}
>
<MemoSimulationPlayground
definition={definition}
columns={tableColumns}
simulation={simulation}
samples={samples}
onRefreshSamples={refreshSamples}
isLoading={isLoading}
/>
</EuiResizablePanel>
</>
)}
</EuiResizableContainer>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner grow={false} color="subdued">
<ManagementBottomBar
onCancel={resetChanges}
onConfirm={saveChanges}
isLoading={isSavingChanges}
disabled={!hasChanges}
/>
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
);
}
const ProcessorsHeader = () => {
return (
<>
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.streams.streamDetailView.managementTab.enrichment.headingTitle', {
defaultMessage: 'Processors for field extraction',
})}
</h2>
</EuiTitle>
<EuiText component="p" size="s">
{i18n.translate('xpack.streams.streamDetailView.managementTab.enrichment.headingSubtitle', {
defaultMessage:
'Use processors to transform data before indexing. Drag and drop existing processors to update their execution order.',
})}
</EuiText>
</>
);
};
interface ProcessorsEditorProps {
definition: IngestStreamGetResponse;
processors: UseDefinitionReturn['processors'];
onAddProcessor: UseDefinitionReturn['addProcessor'];
onDeleteProcessor: UseDefinitionReturn['deleteProcessor'];
onReorderProcessor: UseDefinitionReturn['reorderProcessors'];
onUpdateProcessor: UseDefinitionReturn['updateProcessor'];
onWatchProcessor: UseProcessingSimulatorReturn['watchProcessor'];
}
const ProcessorsEditor = React.memo(
({
definition,
processors,
onAddProcessor,
onDeleteProcessor,
onReorderProcessor,
onUpdateProcessor,
onWatchProcessor,
}: ProcessorsEditorProps) => {
const { euiTheme } = useEuiTheme();
const handlerItemDrag: DragDropContextProps['onDragEnd'] = ({ source, destination }) => {
if (source && destination) {
const items = euiDragDropReorder(processors, source.index, destination.index);
onReorderProcessor(items);
}
};
const hasProcessors = !isEmpty(processors);
return (
<>
<EuiPanel
paddingSize="m"
hasShadow={false}
borderRadius="none"
grow={false}
css={css`
z-index: ${euiTheme.levels.maskBelowHeader};
${useEuiShadow('xs')};
`}
>
<EuiTitle size="xxs">
<h2>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.headingTitle',
{
defaultMessage: 'Processors for field extraction',
}
)}
</h2>
</EuiTitle>
<EuiText component="p" size="xs">
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.headingSubtitle',
{
defaultMessage:
'Drag and drop existing processors to update their execution order.',
}
)}
</EuiText>
</EuiPanel>
<EuiPanel
paddingSize="m"
hasShadow={false}
borderRadius="none"
css={css`
overflow: auto;
`}
>
{hasProcessors && (
<SortableList onDragItem={handlerItemDrag}>
{processors.map((processor, idx) => (
<DraggableProcessorListItem
key={processor.id}
idx={idx}
definition={definition}
processor={processor}
onDeleteProcessor={onDeleteProcessor}
onUpdateProcessor={onUpdateProcessor}
onWatchProcessor={onWatchProcessor}
/>
))}
</SortableList>
)}
<AddProcessorPanel
key={processors.length} // Used to force reset the inner form state once a new processor is added
definition={definition}
onAddProcessor={onAddProcessor}
onWatchProcessor={onWatchProcessor}
/>
</EuiPanel>
</>
);
}
);
const verticalFlexCss = css`
display: flex;
flex-direction: column;
`;

View file

@ -0,0 +1,247 @@
/*
* 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, { useMemo, useState } from 'react';
import { useDateRange } from '@kbn/observability-utils-browser/hooks/use_date_range';
import {
EuiFlexGroup,
EuiFilterButton,
EuiFilterGroup,
EuiEmptyPrompt,
EuiFlexItem,
EuiSpacer,
EuiProgress,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { TimeRange } from '@kbn/es-query';
import { flattenObject } from '@kbn/object-utils';
import { isEmpty } from 'lodash';
import { useKibana } from '../../hooks/use_kibana';
import { StreamsAppSearchBar, StreamsAppSearchBarProps } from '../streams_app_search_bar';
import { PreviewTable } from '../preview_table';
import { TableColumn, UseProcessingSimulatorReturn } from './hooks/use_processing_simulator';
import { AssetImage } from '../asset_image';
interface ProcessorOutcomePreviewProps {
columns: TableColumn[];
isLoading: UseProcessingSimulatorReturn['isLoading'];
simulation: UseProcessingSimulatorReturn['simulation'];
samples: UseProcessingSimulatorReturn['samples'];
onRefreshSamples: UseProcessingSimulatorReturn['refreshSamples'];
}
export const ProcessorOutcomePreview = ({
columns,
isLoading,
simulation,
samples,
onRefreshSamples,
}: ProcessorOutcomePreviewProps) => {
const { dependencies } = useKibana();
const { data } = dependencies.start;
const { timeRange, setTimeRange } = useDateRange({ data });
const [selectedDocsFilter, setSelectedDocsFilter] =
useState<DocsFilterOption>('outcome_filter_all');
const simulationDocuments = useMemo(() => {
if (!simulation?.documents) {
return samples.map((doc) => flattenObject(doc));
}
const filterDocuments = (filter: DocsFilterOption) => {
switch (filter) {
case 'outcome_filter_matched':
return simulation.documents.filter((doc) => doc.isMatch);
case 'outcome_filter_unmatched':
return simulation.documents.filter((doc) => !doc.isMatch);
case 'outcome_filter_all':
default:
return simulation.documents;
}
};
return filterDocuments(selectedDocsFilter).map((doc) => doc.value);
}, [samples, simulation?.documents, selectedDocsFilter]);
const tableColumns = useMemo(() => {
switch (selectedDocsFilter) {
case 'outcome_filter_unmatched':
return columns
.filter((column) => column.origin === 'processor')
.map((column) => column.name);
case 'outcome_filter_matched':
case 'outcome_filter_all':
default:
return columns.map((column) => column.name);
}
}, [columns, selectedDocsFilter]);
return (
<>
<EuiFlexItem grow={false}>
<OutcomeControls
docsFilter={selectedDocsFilter}
onDocsFilterChange={setSelectedDocsFilter}
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
onTimeRangeRefresh={onRefreshSamples}
simulationFailureRate={simulation?.failure_rate}
simulationSuccessRate={simulation?.success_rate}
/>
</EuiFlexItem>
<EuiSpacer size="m" />
<OutcomePreviewTable documents={simulationDocuments} columns={tableColumns} />
{isLoading && <EuiProgress size="xs" color="accent" position="absolute" />}
</>
);
};
const docsFilterOptions = {
outcome_filter_all: {
id: 'outcome_filter_all',
label: i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processor.outcomeControls.all',
{ defaultMessage: 'All samples' }
),
},
outcome_filter_matched: {
id: 'outcome_filter_matched',
label: i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processor.outcomeControls.matched',
{ defaultMessage: 'Matched' }
),
},
outcome_filter_unmatched: {
id: 'outcome_filter_unmatched',
label: i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processor.outcomeControls.unmatched',
{ defaultMessage: 'Unmatched' }
),
},
} as const;
type DocsFilterOption = keyof typeof docsFilterOptions;
interface OutcomeControlsProps {
docsFilter: DocsFilterOption;
timeRange: TimeRange;
onDocsFilterChange: (filter: DocsFilterOption) => void;
onTimeRangeChange: (timeRange: TimeRange) => void;
onTimeRangeRefresh: () => void;
simulationFailureRate?: number;
simulationSuccessRate?: number;
}
const OutcomeControls = ({
docsFilter,
timeRange,
onDocsFilterChange,
onTimeRangeChange,
onTimeRangeRefresh,
simulationFailureRate,
simulationSuccessRate,
}: OutcomeControlsProps) => {
const handleQuerySubmit: StreamsAppSearchBarProps['onQuerySubmit'] = (
{ dateRange },
isUpdate
) => {
if (!isUpdate) {
return onTimeRangeRefresh();
}
if (dateRange) {
onTimeRangeChange({
from: dateRange.from,
to: dateRange?.to,
mode: dateRange.mode,
});
}
};
const getFilterButtonPropsFor = (filterId: DocsFilterOption) => ({
hasActiveFilters: docsFilter === filterId,
onClick: () => onDocsFilterChange(filterId),
});
return (
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" wrap>
<EuiFilterGroup
aria-label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processor.outcomeControlsAriaLabel',
{ defaultMessage: 'Filter for all, matching or unmatching previewed documents.' }
)}
>
<EuiFilterButton {...getFilterButtonPropsFor(docsFilterOptions.outcome_filter_all.id)}>
{docsFilterOptions.outcome_filter_all.label}
</EuiFilterButton>
<EuiFilterButton
{...getFilterButtonPropsFor(docsFilterOptions.outcome_filter_matched.id)}
badgeColor="success"
numActiveFilters={
simulationSuccessRate ? parseFloat((simulationSuccessRate * 100).toFixed(2)) : undefined
}
>
{docsFilterOptions.outcome_filter_matched.label}
</EuiFilterButton>
<EuiFilterButton
{...getFilterButtonPropsFor(docsFilterOptions.outcome_filter_unmatched.id)}
badgeColor="accent"
numActiveFilters={
simulationFailureRate ? parseFloat((simulationFailureRate * 100).toFixed(2)) : undefined
}
>
{docsFilterOptions.outcome_filter_unmatched.label}
</EuiFilterButton>
</EuiFilterGroup>
<StreamsAppSearchBar
onQuerySubmit={handleQuerySubmit}
onRefresh={onTimeRangeRefresh}
dateRangeFrom={timeRange.from}
dateRangeTo={timeRange.to}
/>
</EuiFlexGroup>
);
};
interface OutcomePreviewTableProps {
documents: Array<Record<PropertyKey, unknown>>;
columns: string[];
}
const OutcomePreviewTable = ({ documents, columns }: OutcomePreviewTableProps) => {
if (isEmpty(documents)) {
return (
<EuiEmptyPrompt
titleSize="xs"
icon={<AssetImage type="noResults" />}
title={
<h2>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.rootStreamEmptyPrompt.noDataTitle',
{ defaultMessage: 'Unable to generate a preview' }
)}
</h2>
}
body={
<p>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processor.outcomePreviewTable.noDataBody',
{
defaultMessage:
"There are no sample documents to test the processors. Try updating the time range or ingesting more data, it might be possible we could not find any matching documents with the processors' source fields.",
}
)}
</p>
}
/>
);
}
return <PreviewTable documents={documents} displayColumns={columns} />;
};

View file

@ -18,12 +18,12 @@ export const DissectAppendSeparator = () => {
return (
<EuiFormRow
label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.dissectPatternSeparatorLabel',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.dissectPatternSeparatorLabel',
{ defaultMessage: 'Append separator' }
)}
helpText={
<FormattedMessage
id="xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.dissectPatternSeparatorHelpText"
id="xpack.streams.streamDetailView.managementTab.enrichment.processor.dissectPatternSeparatorHelpText"
defaultMessage="If you specify a key modifier, this character separates the fields when appending results. Defaults to {value}."
values={{ value: <EuiCode>&quot;&quot;</EuiCode> }}
/>

View file

@ -22,7 +22,7 @@ export const DissectPatternDefinition = () => {
name: 'pattern',
rules: {
required: i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.dissectPatternRequiredError',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.dissectPatternRequiredError',
{ defaultMessage: 'A pattern is required.' }
),
},
@ -33,12 +33,12 @@ export const DissectPatternDefinition = () => {
return (
<EuiFormRow
label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.dissectPatternDefinitionsLabel',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.dissectPatternDefinitionsLabel',
{ defaultMessage: 'Pattern' }
)}
helpText={
<FormattedMessage
id="xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.dissectPatternDefinitionsHelpText"
id="xpack.streams.streamDetailView.managementTab.enrichment.processor.dissectPatternDefinitionsHelpText"
defaultMessage="Pattern used to dissect the specified field. The pattern is defined by the parts of the string to discard. Use a {keyModifier} to alter the dissection behavior."
values={{
keyModifier: (
@ -49,7 +49,7 @@ export const DissectPatternDefinition = () => {
href={esDocUrl}
>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.dissectPatternDefinitionsLink',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.dissectPatternDefinitionsLink',
{ defaultMessage: 'key modifier' }
)}
</EuiLink>
@ -68,7 +68,7 @@ export const DissectPatternDefinition = () => {
height={75}
options={{ minimap: { enabled: false } }}
aria-label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.dissectPatternDefinitionsAriaLabel',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.dissectPatternDefinitionsAriaLabel',
{ defaultMessage: 'Pattern editor' }
)}
/>

View file

@ -20,11 +20,11 @@ export const GrokPatternDefinition = () => {
return (
<EuiFormRow
label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.grokPatternDefinitionsLabel',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.grokPatternDefinitionsLabel',
{ defaultMessage: 'Pattern definitions' }
)}
helpText={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.grokPatternDefinitionsHelpText',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.grokPatternDefinitionsHelpText',
{
defaultMessage:
'A map of pattern-name and pattern tuples defining custom patterns. Patterns matching existing names will override the pre-existing definition.',
@ -39,7 +39,7 @@ export const GrokPatternDefinition = () => {
languageId="xjson"
height={200}
aria-label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.grokPatternDefinitionsAriaLabel',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.grokPatternDefinitionsAriaLabel',
{ defaultMessage: 'Pattern definitions editor' }
)}
/>

View file

@ -17,7 +17,6 @@ import {
DragDropContextProps,
EuiFormRow,
EuiPanel,
EuiSpacer,
EuiButtonEmpty,
EuiDraggable,
EuiFlexGroup,
@ -62,11 +61,11 @@ export const GrokPatternsEditor = () => {
return (
<EuiFormRow
label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.grokEditorLabel',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.grokEditorLabel',
{ defaultMessage: 'Grok patterns editor' }
)}
>
<EuiPanel color="subdued" paddingSize="m">
<EuiPanel color="subdued" paddingSize="s">
<SortableList onDragItem={handlerPatternDrag}>
{fieldsWithError.map((field, idx) => (
<DraggablePatternInput
@ -76,22 +75,20 @@ export const GrokPatternsEditor = () => {
onRemove={getRemovePatternHandler(idx)}
inputProps={register(`patterns.${idx}.value`, {
required: i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.grokEditorRequiredError',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.grokEditorRequiredError',
{ defaultMessage: 'A pattern is required.' }
),
})}
/>
))}
</SortableList>
<EuiSpacer size="m" />
<EuiButtonEmpty
data-test-subj="streamsAppGrokPatternsEditorAddPatternButton"
onClick={handleAddPattern}
iconType="plusInCircle"
flush="left"
>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.grokEditor.addPattern',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.grokEditor.addPattern',
{ defaultMessage: 'Add pattern' }
)}
</EuiButtonEmpty>
@ -132,13 +129,13 @@ const DraggablePatternInput = ({
>
{(provided) => (
<EuiFormRow isInvalid={isInvalid} error={error?.message}>
<EuiFlexGroup gutterSize="m" responsive={false} alignItems="center">
<EuiFlexGroup gutterSize="s" responsive={false} alignItems="center">
<EuiPanel
color="transparent"
paddingSize="xs"
{...provided.dragHandleProps}
aria-label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.grokEditor.dragHandleLabel',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.grokEditor.dragHandleLabel',
{ defaultMessage: 'Drag Handle' }
)}
>
@ -158,7 +155,7 @@ const DraggablePatternInput = ({
color="danger"
onClick={() => onRemove(idx)}
aria-label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.grokEditor.removePattern',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.grokEditor.removePattern',
{ defaultMessage: 'Remove grok pattern' }
)}
/>

View file

@ -19,14 +19,14 @@ export const IgnoreFailureToggle = () => {
<ToggleField
name="ignore_failure"
label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.ignoreFailuresLabel',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.ignoreFailuresLabel',
{ defaultMessage: 'Ignore failures for this processor' }
)}
helpText={
!value ? (
<EuiText component="span" size="relative" color="warning">
<FormattedMessage
id="xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.ignoreFailuresWarning"
id="xpack.streams.streamDetailView.managementTab.enrichment.processor.ignoreFailuresWarning"
defaultMessage="Disabling the {ignoreField} option could lead to unexpected pipeline failures."
values={{
ignoreField: <EuiCode>ignore_failure</EuiCode>,
@ -44,11 +44,11 @@ export const IgnoreMissingToggle = () => {
<ToggleField
name="ignore_missing"
label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.ignoreMissingLabel',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.ignoreMissingLabel',
{ defaultMessage: 'Ignore missing' }
)}
helpText={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.ignoreMissingHelpText',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.ignoreMissingHelpText',
{ defaultMessage: 'Ignore documents with a missing field.' }
)}
/>

View file

@ -0,0 +1,394 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButton,
EuiForm,
EuiSpacer,
EuiButtonEmpty,
EuiFlexGroup,
EuiPanel,
useEuiTheme,
EuiHorizontalRule,
EuiAccordion,
EuiButtonIcon,
EuiIcon,
EuiText,
EuiBadge,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ProcessorType, IngestStreamGetResponse } from '@kbn/streams-schema';
import { isEqual } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import { useForm, SubmitHandler, FormProvider, useWatch } from 'react-hook-form';
import { css } from '@emotion/react';
import { useBoolean } from '@kbn/react-hooks';
import { DissectProcessorForm } from './dissect';
import { GrokProcessorForm } from './grok';
import { ProcessorTypeSelector } from './processor_type_selector';
import { ProcessorFormState, ProcessorDefinitionWithUIAttributes } from '../types';
import {
getDefaultFormState,
convertFormStateToProcessor,
isGrokProcessor,
isDissectProcessor,
} from '../utils';
import { useDiscardConfirm } from '../../../hooks/use_discard_confirm';
import { UseDefinitionReturn } from '../hooks/use_definition';
import { UseProcessingSimulatorReturn } from '../hooks/use_processing_simulator';
export interface ProcessorPanelProps {
definition: IngestStreamGetResponse;
onWatchProcessor: UseProcessingSimulatorReturn['watchProcessor'];
}
export interface AddProcessorPanelProps extends ProcessorPanelProps {
isInitiallyOpen?: boolean;
onAddProcessor: UseDefinitionReturn['addProcessor'];
}
export interface EditProcessorPanelProps extends ProcessorPanelProps {
processor: ProcessorDefinitionWithUIAttributes;
onDeleteProcessor: UseDefinitionReturn['deleteProcessor'];
onUpdateProcessor: UseDefinitionReturn['updateProcessor'];
}
export function AddProcessorPanel({ onAddProcessor, onWatchProcessor }: AddProcessorPanelProps) {
const { euiTheme } = useEuiTheme();
const [hasChanges, setHasChanges] = useState(false);
const [isOpen, { on: openPanel, off: closePanel }] = useBoolean(false);
const defaultValues = useMemo(() => getDefaultFormState('grok'), []);
const methods = useForm<ProcessorFormState>({ defaultValues, mode: 'onChange' });
const type = useWatch({ control: methods.control, name: 'type' });
useEffect(() => {
if (isOpen) {
const { unsubscribe } = methods.watch((value) => {
const draftProcessor = createDraftProcessorFromForm(value as ProcessorFormState);
onWatchProcessor(draftProcessor);
setHasChanges(!isEqual(defaultValues, value));
});
return () => unsubscribe();
}
}, [defaultValues, isOpen, methods, onWatchProcessor]);
const handleSubmit: SubmitHandler<ProcessorFormState> = async (data) => {
const processingDefinition = convertFormStateToProcessor(data);
onWatchProcessor({ id: 'draft', deleteIfExists: true });
onAddProcessor(processingDefinition, data.detected_fields);
closePanel();
};
const handleCancel = () => {
methods.reset();
onWatchProcessor({ id: 'draft', deleteIfExists: true });
closePanel();
};
const handleOpen = () => {
const draftProcessor = createDraftProcessorFromForm(defaultValues);
onWatchProcessor(draftProcessor);
openPanel();
};
const confirmDiscardAndClose = useDiscardConfirm(handleCancel);
const buttonContent = isOpen ? (
i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorPanel.addingProcessor',
{ defaultMessage: 'Adding processor' }
)
) : (
<EuiFlexGroup gutterSize="s" responsive={false} alignItems="center">
<EuiIcon type="plus" />
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.addProcessorAction',
{ defaultMessage: 'Add a processor' }
)}
</EuiFlexGroup>
);
return (
<EuiPanel
color={isOpen ? 'subdued' : undefined}
hasBorder
css={css`
border: ${euiTheme.border.thin};
padding: ${euiTheme.size.m};
`}
>
<EuiAccordion
id="add-processor-accordion"
arrowProps={{
css: { display: 'none' },
}}
buttonContent={buttonContent}
buttonElement="div"
forceState={isOpen ? 'open' : 'closed'}
onToggle={handleOpen}
extraAction={
isOpen ? (
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiButtonEmpty
data-test-subj="streamsAppAddProcessorPanelCancelButton"
onClick={hasChanges ? confirmDiscardAndClose : handleCancel}
size="s"
>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorPanel.cancel',
{ defaultMessage: 'Cancel' }
)}
</EuiButtonEmpty>
<EuiButton
data-test-subj="streamsAppAddProcessorPanelAddProcessorButton"
size="s"
onClick={methods.handleSubmit(handleSubmit)}
disabled={!methods.formState.isValid && methods.formState.isSubmitted}
>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorPanel.confirmAddProcessor',
{ defaultMessage: 'Add processor' }
)}
</EuiButton>
</EuiFlexGroup>
) : null
}
>
<EuiSpacer size="s" />
<FormProvider {...methods}>
<EuiForm component="form" fullWidth onSubmit={methods.handleSubmit(handleSubmit)}>
<ProcessorTypeSelector />
<EuiSpacer size="m" />
{type === 'grok' && <GrokProcessorForm />}
{type === 'dissect' && <DissectProcessorForm />}
</EuiForm>
</FormProvider>
</EuiAccordion>
</EuiPanel>
);
}
const createDraftProcessorFromForm = (
formState: ProcessorFormState
): ProcessorDefinitionWithUIAttributes => {
const processingDefinition = convertFormStateToProcessor(formState);
return {
id: 'draft',
status: 'draft',
type: formState.type,
...processingDefinition,
};
};
export function EditProcessorPanel({
onDeleteProcessor,
onUpdateProcessor,
onWatchProcessor,
processor,
}: EditProcessorPanelProps) {
const { euiTheme } = useEuiTheme();
const [hasChanges, setHasChanges] = useState(false);
const [isOpen, { on: openPanel, off: closePanel }] = useBoolean();
const processorDescription = getProcessorDescription(processor);
const isDraft = processor.status === 'draft';
const isUnsaved = isDraft || processor.status === 'updated';
const defaultValues = useMemo(() => getDefaultFormState(processor.type, processor), [processor]);
const methods = useForm<ProcessorFormState>({ defaultValues, mode: 'onChange' });
const type = useWatch({ control: methods.control, name: 'type' });
useEffect(() => {
const { unsubscribe } = methods.watch((value) => {
const processingDefinition = convertFormStateToProcessor(value as ProcessorFormState);
onWatchProcessor({
id: processor.id,
status: processor.status,
type: value.type as ProcessorType,
...processingDefinition,
});
setHasChanges(!isEqual(defaultValues, value));
});
return () => unsubscribe();
}, [defaultValues, methods, onWatchProcessor, processor.id, processor.status]);
const handleSubmit: SubmitHandler<ProcessorFormState> = (data) => {
const processorDefinition = convertFormStateToProcessor(data);
onUpdateProcessor(processor.id, processorDefinition, isDraft ? 'draft' : 'updated');
closePanel();
};
const handleProcessorDelete = () => {
onDeleteProcessor(processor.id);
closePanel();
};
const handleCancel = () => {
methods.reset();
closePanel();
};
const confirmDiscardAndClose = useDiscardConfirm(handleCancel);
const confirmDeletionAndClose = useDiscardConfirm(handleProcessorDelete, {
title: deleteProcessorTitle,
message: deleteProcessorMessage,
confirmButtonText: deleteProcessorLabel,
cancelButtonText: deleteProcessorCancelLabel,
});
const buttonContent = isOpen ? (
<strong>{processor.type.toUpperCase()}</strong>
) : (
<EuiFlexGroup gutterSize="s" responsive={false} alignItems="center">
<EuiIcon type="grab" />
<strong>{processor.type.toUpperCase()}</strong>
<EuiText component="span" size="s" color="subdued" className="eui-textTruncate">
{processorDescription}
</EuiText>
</EuiFlexGroup>
);
return (
<EuiPanel
hasBorder
color={isDraft ? 'subdued' : undefined}
css={css`
border: ${euiTheme.border.thin};
padding: ${euiTheme.size.m};
`}
>
<EuiAccordion
id="edit-processor-accordion"
arrowProps={{
css: { display: 'none' },
}}
buttonContent={buttonContent}
buttonContentClassName="eui-textTruncate"
buttonElement="div"
buttonProps={{
/* Allow text ellipsis in flex child nodes */
css: css`
min-width: 0;
&:is(:hover, :focus) {
cursor: grab;
text-decoration: none;
}
`,
}}
forceState={isOpen ? 'open' : 'closed'}
extraAction={
isOpen ? (
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiButtonEmpty
data-test-subj="streamsAppEditProcessorPanelCancelButton"
onClick={hasChanges ? confirmDiscardAndClose : handleCancel}
size="s"
>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorPanel.cancel',
{ defaultMessage: 'Cancel' }
)}
</EuiButtonEmpty>
<EuiButton
data-test-subj="streamsAppEditProcessorPanelUpdateProcessorButton"
size="s"
onClick={methods.handleSubmit(handleSubmit)}
disabled={!methods.formState.isValid}
>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorPanel.confirmEditProcessor',
{ defaultMessage: 'Update processor' }
)}
</EuiButton>
</EuiFlexGroup>
) : (
<EuiFlexGroup alignItems="center" gutterSize="s">
{isUnsaved && (
<EuiBadge>
{i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorPanel.unsavedBadge',
{ defaultMessage: 'Unsaved' }
)}
</EuiBadge>
)}
<EuiButtonIcon
data-test-subj="streamsAppEditProcessorPanelButton"
onClick={openPanel}
iconType="pencil"
color="text"
size="xs"
aria-label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.editProcessorAction',
{ defaultMessage: 'Edit {type} processor', values: { type: processor.type } }
)}
/>
</EuiFlexGroup>
)
}
>
<EuiSpacer size="s" />
<FormProvider {...methods}>
<EuiForm component="form" fullWidth onSubmit={methods.handleSubmit(handleSubmit)}>
<ProcessorTypeSelector disabled />
<EuiSpacer size="m" />
{type === 'grok' && <GrokProcessorForm />}
{type === 'dissect' && <DissectProcessorForm />}
<EuiHorizontalRule margin="m" />
<EuiButton
data-test-subj="streamsAppEditProcessorPanelButton"
color="danger"
onClick={confirmDeletionAndClose}
>
{deleteProcessorLabel}
</EuiButton>
</EuiForm>
</FormProvider>
</EuiAccordion>
</EuiPanel>
);
}
const deleteProcessorLabel = i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.deleteProcessorLabel',
{ defaultMessage: 'Delete processor' }
);
const deleteProcessorCancelLabel = i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.deleteProcessorCancelLabel',
{ defaultMessage: 'Cancel' }
);
const deleteProcessorTitle = i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.deleteProcessorTitle',
{ defaultMessage: 'Are you sure you want to delete this processor?' }
);
const deleteProcessorMessage = i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.deleteProcessorMessage',
{ defaultMessage: 'Deleting this processor will permanently impact the field configuration.' }
);
const getProcessorDescription = (processor: ProcessorDefinitionWithUIAttributes) => {
if (isGrokProcessor(processor)) {
return processor.grok.patterns.join(' • ');
} else if (isDissectProcessor(processor)) {
return processor.dissect.pattern;
}
return '';
};

View file

@ -19,7 +19,7 @@ export const OptionalFieldsAccordion = ({ children }: PropsWithChildren) => {
id="optionalFieldsAccordion"
paddingSize="none"
buttonContent={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.optionalFields',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.optionalFields',
{ defaultMessage: 'Optional fields' }
)}
>

View file

@ -11,7 +11,7 @@ import { ConditionEditor } from '../../condition_editor';
import { ProcessorFormState } from '../types';
export const ProcessorConditionEditor = () => {
const { field } = useController<ProcessorFormState, 'condition'>({ name: 'condition' });
const { field } = useController<ProcessorFormState, 'if'>({ name: 'if' });
return <ConditionEditor condition={field.value} onConditionChange={field.onChange} />;
};

View file

@ -16,7 +16,7 @@ export const ProcessorFieldSelector = () => {
name: 'field',
rules: {
required: i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.fieldSelectorRequiredError',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.fieldSelectorRequiredError',
{ defaultMessage: 'A field value is required.' }
),
},
@ -28,11 +28,11 @@ export const ProcessorFieldSelector = () => {
return (
<EuiFormRow
label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.fieldSelectorLabel',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.fieldSelectorLabel',
{ defaultMessage: 'Field' }
)}
helpText={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.fieldSelectorHelpText',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.fieldSelectorHelpText',
{ defaultMessage: 'Field to search for matches.' }
)}
isInvalid={invalid}

View file

@ -46,7 +46,7 @@ export const ProcessorTypeSelector = ({
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.typeSelectorLabel',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.typeSelectorLabel',
{ defaultMessage: 'Processor' }
)}
helpText={getProcessorDescription(esDocUrl)(processorType)}
@ -59,7 +59,7 @@ export const ProcessorTypeSelector = ({
onChange={handleChange}
fullWidth
placeholder={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.typeSelectorPlaceholder',
'xpack.streams.streamDetailView.managementTab.enrichment.processor.typeSelectorPlaceholder',
{ defaultMessage: 'Grok, Dissect ...' }
)}
/>
@ -73,7 +73,7 @@ const availableProcessors: TAvailableProcessors = {
inputDisplay: 'Dissect',
getDocUrl: (esDocUrl: string) => (
<FormattedMessage
id="xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.dissectHelpText"
id="xpack.streams.streamDetailView.managementTab.enrichment.processor.dissectHelpText"
defaultMessage="Uses {dissectLink} patterns to extract matches from a field."
values={{
dissectLink: (
@ -97,7 +97,7 @@ const availableProcessors: TAvailableProcessors = {
inputDisplay: 'Grok',
getDocUrl: (esDocUrl: string) => (
<FormattedMessage
id="xpack.streams.streamDetailView.managementTab.enrichment.processorFlyout.grokHelpText"
id="xpack.streams.streamDetailView.managementTab.enrichment.processor.grokHelpText"
defaultMessage="Uses {grokLink} expressions to extract matches from a field."
values={{
grokLink: (

View file

@ -6,28 +6,14 @@
*/
import React from 'react';
import {
EuiDraggable,
EuiPanelProps,
EuiPanel,
EuiFlexGroup,
EuiIcon,
EuiText,
EuiFlexItem,
EuiButtonIcon,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ReadStreamDefinition } from '@kbn/streams-schema';
import { useBoolean } from '@kbn/react-hooks';
import { css } from '@emotion/react';
import { EditProcessorFlyout, EditProcessorFlyoutProps } from './flyout';
import { EnrichmentUIProcessorDefinition, isDissectProcessor, isGrokProcessor } from './types';
import { EuiDraggable } from '@elastic/eui';
import { EditProcessorPanel, type EditProcessorPanelProps } from './processors';
export const DraggableProcessorListItem = ({
processor,
idx,
...props
}: Omit<ProcessorListItemProps, 'hasShadow'> & { idx: number }) => (
}: EditProcessorPanelProps & { idx: number }) => (
<EuiDraggable
index={idx}
spacing="m"
@ -38,81 +24,6 @@ export const DraggableProcessorListItem = ({
paddingRight: 0,
}}
>
{(_provided, state) => (
<ProcessorListItem processor={processor} hasShadow={state.isDragging} {...props} />
)}
{() => <EditProcessorPanel processor={processor} {...props} />}
</EuiDraggable>
);
interface ProcessorListItemProps {
definition: ReadStreamDefinition;
processor: EnrichmentUIProcessorDefinition;
hasShadow: EuiPanelProps['hasShadow'];
onUpdateProcessor: EditProcessorFlyoutProps['onUpdateProcessor'];
onDeleteProcessor: EditProcessorFlyoutProps['onDeleteProcessor'];
}
const ProcessorListItem = ({
definition,
processor,
hasShadow = false,
onUpdateProcessor,
onDeleteProcessor,
}: ProcessorListItemProps) => {
const [isEditProcessorOpen, { on: openEditProcessor, off: closeEditProcessor }] = useBoolean();
const type = 'grok' in processor.config ? 'grok' : 'dissect';
const description = getProcessorDescription(processor);
return (
<EuiPanel hasBorder hasShadow={hasShadow} paddingSize="s">
<EuiFlexGroup gutterSize="m" responsive={false} alignItems="center">
<EuiIcon type="grab" />
<EuiText component="span" size="s">
{type.toUpperCase()}
</EuiText>
<EuiFlexItem
/* Allow text to overflow in flex child nodes */
css={css`
min-width: 0;
`}
>
<EuiText component="span" size="s" color="subdued" className="eui-textTruncate">
{description}
</EuiText>
</EuiFlexItem>
<EuiButtonIcon
data-test-subj="streamsAppProcessorListItemButton"
onClick={openEditProcessor}
iconType="pencil"
color="text"
size="s"
aria-label={i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.editProcessorAction',
{ defaultMessage: 'Edit {type} processor', values: { type } }
)}
/>
</EuiFlexGroup>
{isEditProcessorOpen && (
<EditProcessorFlyout
key={`edit-processor`}
definition={definition}
processor={processor}
onClose={closeEditProcessor}
onUpdateProcessor={onUpdateProcessor}
onDeleteProcessor={onDeleteProcessor}
/>
)}
</EuiPanel>
);
};
const getProcessorDescription = (processor: EnrichmentUIProcessorDefinition) => {
if (isGrokProcessor(processor.config)) {
return processor.config.grok.patterns.join(' • ');
} else if (isDissectProcessor(processor.config)) {
return processor.config.dissect.pattern;
}
return '';
};

View file

@ -0,0 +1,77 @@
/*
* 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, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFlexItem, EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui';
import { IngestStreamGetResponse, isWiredStreamGetResponse } from '@kbn/streams-schema';
import { ProcessorOutcomePreview } from './processor_outcome_preview';
import { TableColumn, UseProcessingSimulatorReturn } from './hooks/use_processing_simulator';
interface SimulationPlaygroundProps {
definition: IngestStreamGetResponse;
columns: TableColumn[];
isLoading: UseProcessingSimulatorReturn['isLoading'];
simulation: UseProcessingSimulatorReturn['simulation'];
samples: UseProcessingSimulatorReturn['samples'];
onRefreshSamples: UseProcessingSimulatorReturn['refreshSamples'];
}
export const SimulationPlayground = (props: SimulationPlaygroundProps) => {
const { definition, columns, isLoading, simulation, samples, onRefreshSamples } = props;
const tabs = {
dataPreview: {
name: i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.simulationPlayground.dataPreview',
{ defaultMessage: 'Data preview' }
),
},
...(isWiredStreamGetResponse(definition) && {
detectedFields: {
name: i18n.translate(
'xpack.streams.streamDetailView.managementTab.enrichment.simulationPlayground.detectedFields',
{ defaultMessage: 'Detected fields' }
),
},
}),
} as const;
const [selectedTabId, setSelectedTabId] = useState<keyof typeof tabs>('dataPreview');
return (
<>
<EuiFlexItem grow={false}>
<EuiTabs bottomBorder={false}>
{Object.entries(tabs).map(([tabId, tab]) => (
<EuiTab
key={tabId}
isSelected={selectedTabId === tabId}
onClick={() => setSelectedTabId(tabId as keyof typeof tabs)}
>
{tab.name}
</EuiTab>
))}
</EuiTabs>
</EuiFlexItem>
<EuiSpacer size="m" />
{selectedTabId === 'dataPreview' && (
<ProcessorOutcomePreview
columns={columns}
isLoading={isLoading}
simulation={simulation}
samples={samples}
onRefreshSamples={onRefreshSamples}
/>
)}
{selectedTabId === 'detectedFields' &&
i18n.translate('xpack.streams.simulationPlayground.div.detectedFieldsLabel', {
defaultMessage: 'WIP',
})}
</>
);
};

View file

@ -29,6 +29,7 @@ export const SortableList = ({ onDragItem, children }: SortableListProps) => {
droppableId="droppable-area"
css={css`
background-color: ${euiTheme.colors.backgroundTransparent};
margin-bottom: ${euiTheme.size.s};
`}
>
{children}

View file

@ -6,65 +6,36 @@
*/
import {
Condition,
DissectProcessorConfig,
FieldDefinitionConfig,
FieldDefinitionType,
GrokProcessorConfig,
ProcessorDefinition,
ProcessorTypeOf,
} from '@kbn/streams-schema';
export interface DissectProcessingDefinition {
dissect: Omit<DissectProcessorConfig, 'if'>;
}
export interface GrokProcessingDefinition {
grok: Omit<GrokProcessorConfig, 'if'>;
}
export interface ProcessingDefinition {
condition: Condition;
config: DissectProcessingDefinition | GrokProcessingDefinition;
}
export interface EnrichmentUIProcessorDefinition extends ProcessingDefinition {
export type WithUIAttributes<T extends ProcessorDefinition> = T & {
id: string;
type: ProcessorTypeOf<T>;
status: 'draft' | 'saved' | 'updated';
};
export type ProcessorDefinitionWithUIAttributes = WithUIAttributes<ProcessorDefinition>;
export interface DetectedField {
name: string;
type: FieldDefinitionType | 'unmapped';
}
export interface ProcessingDefinitionGrok extends Pick<ProcessingDefinition, 'condition'> {
config: GrokProcessingDefinition;
}
export interface ProcessingDefinitionDissect extends Pick<ProcessingDefinition, 'condition'> {
config: DissectProcessingDefinition;
}
interface BaseFormState extends Pick<ProcessingDefinition, 'condition'> {
interface BaseFormState {
detected_fields?: DetectedField[];
}
export type GrokFormState = BaseFormState &
Omit<GrokProcessingDefinition['grok'], 'patterns'> & {
Omit<GrokProcessorConfig, 'patterns'> & {
type: 'grok';
patterns: Array<{ value: string }>;
};
export type DissectFormState = DissectProcessingDefinition['dissect'] &
BaseFormState & { type: 'dissect' };
export type DissectFormState = BaseFormState & DissectProcessorConfig & { type: 'dissect' };
export type ProcessorFormState = GrokFormState | DissectFormState;
export interface DetectedField {
name: string;
type: FieldDefinitionConfig['type'] | 'unmapped';
}
export function isGrokProcessor(
config: ProcessingDefinition['config']
): config is GrokProcessingDefinition {
return 'grok' in config;
}
export function isDissectProcessor(
config: ProcessingDefinition['config']
): config is DissectProcessingDefinition {
return 'dissect' in config;
}

View file

@ -7,21 +7,17 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { ProcessorType, conditionSchema, createIsNarrowSchema } from '@kbn/streams-schema';
import { ProcessorDefinition, ProcessorType, getProcessorType } from '@kbn/streams-schema';
import { htmlIdGenerator } from '@elastic/eui';
import { isEmpty } from 'lodash';
import { z } from '@kbn/zod';
import {
DissectFormState,
DissectProcessingDefinition,
EnrichmentUIProcessorDefinition,
ProcessorDefinitionWithUIAttributes,
GrokFormState,
GrokProcessingDefinition,
ProcessingDefinition,
ProcessorFormState,
isDissectProcessor,
isGrokProcessor,
WithUIAttributes,
} from './types';
import { EMPTY_EQUALS_CONDITION } from '../../util/condition';
import { ALWAYS_CONDITION } from '../../util/condition';
const defaultGrokProcessorFormState: GrokFormState = {
type: 'grok',
@ -30,7 +26,7 @@ const defaultGrokProcessorFormState: GrokFormState = {
pattern_definitions: {},
ignore_failure: true,
ignore_missing: true,
condition: EMPTY_EQUALS_CONDITION,
if: ALWAYS_CONDITION,
};
const defaultDissectProcessorFormState: DissectFormState = {
@ -39,7 +35,7 @@ const defaultDissectProcessorFormState: DissectFormState = {
pattern: '',
ignore_failure: true,
ignore_missing: true,
condition: EMPTY_EQUALS_CONDITION,
if: ALWAYS_CONDITION,
};
const defaultProcessorFormStateByType: Record<ProcessorType, ProcessorFormState> = {
@ -49,67 +45,59 @@ const defaultProcessorFormStateByType: Record<ProcessorType, ProcessorFormState>
export const getDefaultFormState = (
type: ProcessorType,
processor?: EnrichmentUIProcessorDefinition
processor?: ProcessorDefinitionWithUIAttributes
): ProcessorFormState => {
if (!processor) return defaultProcessorFormStateByType[type];
if (isGrokProcessor(processor.config)) {
const { grok } = processor.config;
if (isGrokProcessor(processor)) {
const { grok } = processor;
return structuredClone({
...grok,
condition: processor.condition,
type: 'grok',
patterns: grok.patterns.map((pattern) => ({ value: pattern })),
});
}
const { dissect } = processor.config;
if (isDissectProcessor(processor)) {
const { dissect } = processor;
return structuredClone({
...dissect,
condition: processor.condition,
type: 'dissect',
});
return structuredClone({
...dissect,
type: 'dissect',
});
}
throw new Error(`Default state not found for unsupported processor type: ${type}`);
};
export const convertFormStateToProcessing = (
formState: ProcessorFormState
): ProcessingDefinition => {
export const convertFormStateToProcessor = (formState: ProcessorFormState): ProcessorDefinition => {
if (formState.type === 'grok') {
const { condition, patterns, field, pattern_definitions, ignore_failure, ignore_missing } =
formState;
const { patterns, field, pattern_definitions, ignore_failure, ignore_missing } = formState;
return {
condition,
config: {
grok: {
patterns: patterns
.filter(({ value }) => value.trim().length > 0)
.map(({ value }) => value),
field,
pattern_definitions,
ignore_failure,
ignore_missing,
},
grok: {
if: formState.if,
patterns: patterns.filter(({ value }) => value.trim().length > 0).map(({ value }) => value),
field,
pattern_definitions,
ignore_failure,
ignore_missing,
},
};
}
if (formState.type === 'dissect') {
const { condition, field, pattern, append_separator, ignore_failure, ignore_missing } =
formState;
const { field, pattern, append_separator, ignore_failure, ignore_missing } = formState;
return {
condition,
config: {
dissect: {
field,
pattern,
append_separator,
ignore_failure,
ignore_missing,
},
dissect: {
if: formState.if,
field,
pattern,
append_separator: isEmpty(append_separator) ? undefined : append_separator,
ignore_failure,
ignore_missing,
},
};
}
@ -117,27 +105,36 @@ export const convertFormStateToProcessing = (
throw new Error('Cannot convert form state to processing: unknown type.');
};
export const isCompleteCondition = createIsNarrowSchema(z.unknown(), conditionSchema);
const createProcessorGuardByType =
<TProcessorType extends ProcessorType>(type: TProcessorType) =>
(
processor: ProcessorDefinitionWithUIAttributes
): processor is WithUIAttributes<
Extract<ProcessorDefinition, { [K in TProcessorType]: unknown }>
> =>
processor.type === type;
export const isCompleteGrokDefinition = (processing: GrokProcessingDefinition) => {
const { patterns } = processing.grok;
export const isGrokProcessor = createProcessorGuardByType('grok');
export const isDissectProcessor = createProcessorGuardByType('dissect');
return !isEmpty(patterns);
const createId = htmlIdGenerator();
const toUIDefinition = <TProcessorDefinition extends ProcessorDefinition>(
processor: TProcessorDefinition,
uiAttributes: Partial<Pick<WithUIAttributes<TProcessorDefinition>, 'status'>> = {}
): ProcessorDefinitionWithUIAttributes => ({
id: createId(),
status: 'saved',
type: getProcessorType(processor),
...uiAttributes,
...processor,
});
const toAPIDefinition = (processor: ProcessorDefinitionWithUIAttributes): ProcessorDefinition => {
const { id, status, type, ...processorConfig } = processor;
return processorConfig;
};
export const isCompleteDissectDefinition = (processing: DissectProcessingDefinition) => {
const { pattern } = processing.dissect;
return !isEmpty(pattern);
};
export const isCompleteProcessingDefinition = (processing: ProcessingDefinition) => {
if (isGrokProcessor(processing.config)) {
return isCompleteGrokDefinition(processing.config);
}
if (isDissectProcessor(processing.config)) {
return isCompleteDissectDefinition(processing.config);
}
return false;
export const processorConverter = {
toAPIDefinition,
toUIDefinition,
};

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { UnwiredStreamGetResponse } from '@kbn/streams-schema';
import { EuiCallOut, EuiFlexGroup, EuiListGroup, EuiText } from '@elastic/eui';
@ -31,23 +31,6 @@ export function ClassicStreamDetailManagement({
path: { key, subtab },
} = useStreamsAppParams('/{key}/management/{subtab}');
const legacyDefinition = useMemo(() => {
if (!definition) {
return undefined;
}
return {
dashboards: definition.dashboards,
elasticsearch_assets: definition.elasticsearch_assets,
inherited_fields: {},
effective_lifecycle: definition.effective_lifecycle,
name: definition.stream.name,
data_stream_exists: definition.data_stream_exists,
stream: {
...definition.stream,
},
};
}, [definition]);
const tabs: ManagementTabs = {
overview: {
content: <UnmanagedStreamOverview definition={definition} />,
@ -60,10 +43,7 @@ export function ClassicStreamDetailManagement({
if (definition.data_stream_exists) {
tabs.enrich = {
content: (
<StreamDetailEnrichment
definition={legacyDefinition}
refreshDefinition={refreshDefinition}
/>
<StreamDetailEnrichment definition={definition} refreshDefinition={refreshDefinition} />
),
label: i18n.translate('xpack.streams.streamDetailView.enrichmentTab', {
defaultMessage: 'Extract field',

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { WiredStreamGetResponse } from '@kbn/streams-schema';
import { useStreamsAppParams } from '../../hooks/use_streams_app_params';
@ -33,22 +33,6 @@ export function WiredStreamDetailManagement({
path: { key, subtab },
} = useStreamsAppParams('/{key}/management/{subtab}');
const legacyDefinition = useMemo(() => {
if (!definition) {
return undefined;
}
return {
dashboards: definition.dashboards,
inherited_fields: definition.inherited_fields,
elasticsearch_assets: [],
effective_lifecycle: definition.effective_lifecycle,
name: definition.stream.name,
stream: {
...definition.stream,
},
};
}, [definition]);
const tabs = {
route: {
content: (
@ -60,10 +44,7 @@ export function WiredStreamDetailManagement({
},
enrich: {
content: (
<StreamDetailEnrichment
definition={legacyDefinition}
refreshDefinition={refreshDefinition}
/>
<StreamDetailEnrichment definition={definition} refreshDefinition={refreshDefinition} />
),
label: i18n.translate('xpack.streams.streamDetailView.enrichmentTab', {
defaultMessage: 'Extract field',

View file

@ -306,7 +306,7 @@ function ChildStreamList({ definition }: { definition?: IngestStreamGetResponse
[streamsRepositoryClient]
);
const childDefinitions = useMemo(() => {
const childrenStreams = useMemo(() => {
if (!definition) {
return [];
}
@ -315,7 +315,7 @@ function ChildStreamList({ definition }: { definition?: IngestStreamGetResponse
);
}, [definition, streamsListFetch.value?.streams]);
if (definition && childDefinitions?.length === 1) {
if (definition && childrenStreams?.length === 1) {
return (
<EuiFlexItem grow>
<EuiFlexGroup alignItems="center" justifyContent="center">
@ -361,5 +361,5 @@ function ChildStreamList({ definition }: { definition?: IngestStreamGetResponse
);
}
return <StreamsList definitions={childDefinitions} showControls={false} />;
return <StreamsList streams={childrenStreams} showControls={false} />;
}

View file

@ -36,7 +36,6 @@ import { useAbortController } from '@kbn/observability-utils-browser/hooks/use_a
import { useDateRange } from '@kbn/observability-utils-browser/hooks/use_date_range';
import React, { useCallback, useEffect } from 'react';
import {
ReadStreamDefinition,
isRoot,
isDescendantOf,
RoutingDefinition,
@ -93,9 +92,9 @@ function useRoutingState({
);
// Child streams: either represents the child streams as they are, or the new order from drag and drop.
const [childStreams, setChildStreams] = React.useState<
ReadStreamDefinition['stream']['ingest']['routing']
>(definition?.stream.ingest.routing ?? []);
const [childStreams, setChildStreams] = React.useState<RoutingDefinition[]>(
definition?.stream.ingest.routing ?? []
);
useEffect(() => {
setChildStreams(definition?.stream.ingest.routing ?? []);

View file

@ -61,7 +61,7 @@ export function StreamListView() {
/>
</EuiFlexItem>
<EuiFlexItem grow>
<StreamsList definitions={streamsListFetch.value?.streams} query={query} showControls />
<StreamsList streams={streamsListFetch.value?.streams} query={query} showControls />
</EuiFlexItem>
</EuiFlexGroup>
</StreamsAppPageBody>

View file

@ -35,31 +35,29 @@ import { getIndexPatterns } from '../../util/hierarchy_helpers';
export interface StreamTree {
name: string;
type: 'wired' | 'root' | 'classic';
definition: StreamDefinition;
stream: StreamDefinition;
children: StreamTree[];
}
function asTrees(definitions: StreamDefinition[]) {
function asTrees(streams: StreamDefinition[]) {
const trees: StreamTree[] = [];
const wiredDefinitions = definitions.filter((definition) => isWiredStreamDefinition(definition));
wiredDefinitions.sort((a, b) => getSegments(a.name).length - getSegments(b.name).length);
const wiredStreams = streams.filter(isWiredStreamDefinition);
wiredStreams.sort((a, b) => getSegments(a.name).length - getSegments(b.name).length);
wiredDefinitions.forEach((definition) => {
wiredStreams.forEach((stream) => {
let currentTree = trees;
let existingNode: StreamTree | undefined;
const segments = getSegments(definition.name);
const segments = getSegments(stream.name);
// traverse the tree following the prefix of the current id.
// once we reach the leaf, the current id is added as child - this works because the ids are sorted by depth
while (
(existingNode = currentTree.find((node) => isDescendantOf(node.name, definition.name)))
) {
while ((existingNode = currentTree.find((node) => isDescendantOf(node.name, stream.name)))) {
currentTree = existingNode.children;
}
if (!existingNode) {
const newNode: StreamTree = {
name: definition.name,
name: stream.name,
children: [],
definition,
stream,
type: segments.length === 1 ? 'root' : 'wired',
};
currentTree.push(newNode);
@ -70,19 +68,19 @@ function asTrees(definitions: StreamDefinition[]) {
}
export function StreamsList({
definitions,
streams,
query,
showControls,
}: {
definitions: StreamDefinition[] | undefined;
streams: StreamDefinition[] | undefined;
query?: string;
showControls: boolean;
}) {
const [collapsed, setCollapsed] = React.useState<Record<string, boolean>>({});
const [showClassic, setShowClassic] = React.useState(true);
const items = useMemo(() => {
return definitions ?? [];
}, [definitions]);
return streams ?? [];
}, [streams]);
const filteredItems = useMemo(() => {
return items
@ -96,10 +94,10 @@ export function StreamsList({
const treeView = useMemo(() => {
const trees = asTrees(filteredItems);
const classicList = classicStreams.map((definition) => ({
name: definition.name,
const classicList = classicStreams.map((stream) => ({
name: stream.name,
type: 'classic' as const,
definition,
stream,
children: [],
}));
return [...trees, ...classicList];
@ -190,7 +188,7 @@ function StreamNode({
);
const discoverUrl = useMemo(() => {
const indexPatterns = getIndexPatterns(node.definition);
const indexPatterns = getIndexPatterns(node.stream);
if (!discoverLocator || !indexPatterns) {
return undefined;

View file

@ -6,30 +6,35 @@
*/
import { i18n } from '@kbn/i18n';
import { OverlayModalConfirmOptions } from '@kbn/core/public';
import { useKibana } from './use_kibana';
export const useDiscardConfirm = (handler: () => void) => {
const defaultMessage = i18n.translate('xpack.streams.cancelModal.message', {
defaultMessage: 'Are you sure you want to discard your changes?',
});
export const useDiscardConfirm = <THandler extends (..._args: any[]) => any>(
handler: THandler,
options: OverlayModalConfirmOptions & { message?: string } = {}
) => {
const { core } = useKibana();
const { message = defaultMessage, ...optionsOverride } = options;
return async () => {
const hasCancelled = await core.overlays.openConfirm(
i18n.translate('xpack.streams.cancelModal.message', {
defaultMessage: 'Are you sure you want to discard your changes?',
return async (...args: Parameters<THandler>) => {
const hasCancelled = await core.overlays.openConfirm(message, {
buttonColor: 'danger',
title: i18n.translate('xpack.streams.cancelModal.title', {
defaultMessage: 'Discard changes?',
}),
{
buttonColor: 'danger',
title: i18n.translate('xpack.streams.cancelModal.title', {
defaultMessage: 'Discard changes?',
}),
confirmButtonText: i18n.translate('xpack.streams.cancelModal.confirm', {
defaultMessage: 'Discard',
}),
cancelButtonText: i18n.translate('xpack.streams.cancelModal.cancel', {
defaultMessage: 'Keep editing',
}),
}
);
confirmButtonText: i18n.translate('xpack.streams.cancelModal.confirm', {
defaultMessage: 'Discard',
}),
cancelButtonText: i18n.translate('xpack.streams.cancelModal.cancel', {
defaultMessage: 'Keep editing',
}),
...optionsOverride,
});
if (hasCancelled) handler();
if (hasCancelled) handler(...args);
};
};

View file

@ -19,6 +19,8 @@ export const EMPTY_EQUALS_CONDITION: BinaryFilterCondition = Object.freeze({
value: '',
});
export const ALWAYS_CONDITION: AlwaysCondition = Object.freeze({ always: {} });
export function alwaysToEmptyEquals<T extends Condition>(condition: T): Exclude<T, AlwaysCondition>;
export function alwaysToEmptyEquals(condition: Condition) {
@ -30,7 +32,7 @@ export function alwaysToEmptyEquals(condition: Condition) {
export function emptyEqualsToAlways(condition: Condition) {
if (isEqual(condition, EMPTY_EQUALS_CONDITION)) {
return { always: {} };
return ALWAYS_CONDITION;
}
return condition;
}

View file

@ -7,15 +7,15 @@
import { StreamDefinition, isUnwiredStreamDefinition } from '@kbn/streams-schema';
export function getIndexPatterns(definition: StreamDefinition | undefined) {
if (!definition) {
export function getIndexPatterns(stream: StreamDefinition | undefined) {
if (!stream) {
return undefined;
}
if (!isUnwiredStreamDefinition(definition)) {
return [definition.name];
if (!isUnwiredStreamDefinition(stream)) {
return [stream.name];
}
const isRoot = definition.name.indexOf('.') === -1;
const dataStreamOfDefinition = definition.name;
const isRoot = stream.name.indexOf('.') === -1;
const dataStreamOfDefinition = stream.name;
return isRoot
? [dataStreamOfDefinition, `${dataStreamOfDefinition}.*`]
: [`${dataStreamOfDefinition}*`];

View file

@ -57,7 +57,6 @@
"@kbn/deeplinks-analytics",
"@kbn/dashboard-plugin",
"@kbn/react-kibana-mount",
"@kbn/fields-metadata-plugin",
"@kbn/zod"
"@kbn/fields-metadata-plugin"
]
}

View file

@ -10,7 +10,6 @@ import {
IngestStreamEffectiveLifecycle,
IngestStreamLifecycle,
IngestStreamUpsertRequest,
WiredReadStreamDefinition,
WiredStreamGetResponse,
asIngestStreamGetResponse,
isDslLifecycle,
@ -104,12 +103,10 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
expect(response).to.have.property('acknowledged', true);
const updatedRootDefinition = await getStream(apiClient, 'logs');
expect((updatedRootDefinition as WiredReadStreamDefinition).stream.ingest.lifecycle).to.eql(
{
dsl: { data_retention: '999d' },
}
);
expect((updatedRootDefinition as WiredReadStreamDefinition).effective_lifecycle).to.eql({
expect((updatedRootDefinition as WiredStreamGetResponse).stream.ingest.lifecycle).to.eql({
dsl: { data_retention: '999d' },
});
expect((updatedRootDefinition as WiredStreamGetResponse).effective_lifecycle).to.eql({
dsl: { data_retention: '999d' },
from: 'logs',
});