diff --git a/x-pack/solutions/observability/plugins/streams/common/types.ts b/x-pack/solutions/observability/plugins/streams/common/types.ts index 3d8e0fc0d390c..7917864706c2d 100644 --- a/x-pack/solutions/observability/plugins/streams/common/types.ts +++ b/x-pack/solutions/observability/plugins/streams/common/types.ts @@ -73,6 +73,7 @@ export type ProcessingDefinition = z.infer; export const fieldDefinitionSchema = z.object({ name: z.string(), type: z.enum(['keyword', 'match_only_text', 'long', 'double', 'date', 'boolean', 'ip']), + format: z.optional(z.string()), }); export type FieldDefinition = z.infer; diff --git a/x-pack/solutions/observability/plugins/streams/server/lib/streams/component_templates/generate_layer.ts b/x-pack/solutions/observability/plugins/streams/server/lib/streams/component_templates/generate_layer.ts index a99b9be261911..04dcc8c5dafcb 100644 --- a/x-pack/solutions/observability/plugins/streams/server/lib/streams/component_templates/generate_layer.ts +++ b/x-pack/solutions/observability/plugins/streams/server/lib/streams/component_templates/generate_layer.ts @@ -29,6 +29,9 @@ export function generateLayer( // @timestamp can't ignore malformed dates as it's used for sorting in logsdb (property as MappingDateProperty).ignore_malformed = false; } + if (field.type === 'date' && field.format) { + (property as MappingDateProperty).format = field.format; + } properties[field.name] = property; }); return { diff --git a/x-pack/solutions/observability/plugins/streams/server/routes/index.ts b/x-pack/solutions/observability/plugins/streams/server/routes/index.ts index cf130e99db3fc..e6c53e33e217e 100644 --- a/x-pack/solutions/observability/plugins/streams/server/routes/index.ts +++ b/x-pack/solutions/observability/plugins/streams/server/routes/index.ts @@ -15,6 +15,8 @@ import { listStreamsRoute } from './streams/list'; import { readStreamRoute } from './streams/read'; import { resyncStreamsRoute } from './streams/resync'; import { sampleStreamRoute } from './streams/sample'; +import { schemaFieldsSimulationRoute } from './streams/schema/fields_simulation'; +import { unmappedFieldsRoute } from './streams/schema/unmapped_fields'; import { streamsStatusRoutes } from './streams/settings'; export const streamsRouteRepository = { @@ -29,6 +31,8 @@ export const streamsRouteRepository = { ...esqlRoutes, ...disableStreamsRoute, ...sampleStreamRoute, + ...unmappedFieldsRoute, + ...schemaFieldsSimulationRoute, }; export type StreamsRouteRepository = typeof streamsRouteRepository; diff --git a/x-pack/solutions/observability/plugins/streams/server/routes/streams/schema/fields_simulation.ts b/x-pack/solutions/observability/plugins/streams/server/routes/streams/schema/fields_simulation.ts new file mode 100644 index 0000000000000..01aa61a302a39 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams/server/routes/streams/schema/fields_simulation.ts @@ -0,0 +1,207 @@ +/* + * 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 { notFound, internal } from '@hapi/boom'; +import { getFlattenedObject } from '@kbn/std'; +import { fieldDefinitionSchema } from '../../../../common/types'; +import { createServerRoute } from '../../create_server_route'; +import { DefinitionNotFound } from '../../../lib/streams/errors'; +import { checkReadAccess } from '../../../lib/streams/stream_crud'; + +const SAMPLE_SIZE = 200; + +export const schemaFieldsSimulationRoute = createServerRoute({ + endpoint: 'POST /api/streams/{id}/schema/fields_simulation', + options: { + access: 'internal', + }, + security: { + authz: { + enabled: false, + reason: + 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', + }, + }, + params: z.object({ + path: z.object({ id: z.string() }), + body: z.object({ + field_definitions: z.array(fieldDefinitionSchema), + }), + }), + handler: async ({ + response, + params, + request, + logger, + getScopedClients, + }): Promise<{ + status: 'unknown' | 'success' | 'failure'; + simulationError: string | null; + documentsWithRuntimeFieldsApplied: unknown[] | null; + }> => { + try { + const { scopedClusterClient } = await getScopedClients({ request }); + + const hasAccess = await checkReadAccess({ id: params.path.id, scopedClusterClient }); + if (!hasAccess) { + throw new DefinitionNotFound(`Stream definition for ${params.path.id} not found.`); + } + + const propertiesForSample = Object.fromEntries( + params.body.field_definitions.map((field) => [field.name, { type: 'keyword' }]) + ); + + const documentSamplesSearchBody = { + // Add keyword runtime mappings so we can pair with exists, this is to attempt to "miss" less documents for the simulation. + runtime_mappings: propertiesForSample, + query: { + bool: { + filter: Object.keys(propertiesForSample).map((field) => ({ + exists: { field }, + })), + }, + }, + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + size: SAMPLE_SIZE, + }; + + const sampleResults = await scopedClusterClient.asCurrentUser.search({ + index: params.path.id, + ...documentSamplesSearchBody, + }); + + if ( + (typeof sampleResults.hits.total === 'object' && sampleResults.hits.total?.value === 0) || + sampleResults.hits.total === 0 || + !sampleResults.hits.total + ) { + return { + status: 'unknown', + simulationError: null, + documentsWithRuntimeFieldsApplied: null, + }; + } + + const propertiesForSimulation = Object.fromEntries( + params.body.field_definitions.map((field) => [ + field.name, + { type: field.type, ...(field.format ? { format: field.format } : {}) }, + ]) + ); + + const fieldDefinitionKeys = Object.keys(propertiesForSimulation); + + const sampleResultsAsSimulationDocs = sampleResults.hits.hits.map((hit) => ({ + _index: params.path.id, + _id: hit._id, + _source: Object.fromEntries( + Object.entries(getFlattenedObject(hit._source as Record)).filter( + ([k]) => fieldDefinitionKeys.includes(k) || k === '@timestamp' + ) + ), + })); + + const simulationBody = { + docs: sampleResultsAsSimulationDocs, + component_template_substitutions: { + [`${params.path.id}@stream.layer`]: { + template: { + mappings: { + dynamic: 'strict', + properties: propertiesForSimulation, + }, + }, + }, + }, + }; + + // TODO: We should be using scopedClusterClient.asCurrentUser.simulate.ingest() but the ES JS lib currently has a bug. The types also aren't available yet, so we use any. + const simulation = (await scopedClusterClient.asCurrentUser.transport.request({ + method: 'POST', + path: `_ingest/_simulate`, + body: simulationBody, + })) as any; + + const hasErrors = simulation.docs.some((doc: any) => doc.doc.error !== undefined); + + if (hasErrors) { + const documentWithError = simulation.docs.find((doc: any) => { + return doc.doc.error !== undefined; + }); + + return { + status: 'failure', + simulationError: JSON.stringify( + // Use the first error as a representative error + documentWithError.doc.error + ), + documentsWithRuntimeFieldsApplied: null, + }; + } + + // Convert the field definitions to a format that can be used in runtime mappings (match_only_text -> keyword) + const propertiesCompatibleWithRuntimeMappings = Object.fromEntries( + params.body.field_definitions.map((field) => [ + field.name, + { + type: field.type === 'match_only_text' ? 'keyword' : field.type, + ...(field.format ? { format: field.format } : {}), + }, + ]) + ); + + const runtimeFieldsSearchBody = { + runtime_mappings: propertiesCompatibleWithRuntimeMappings, + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + size: SAMPLE_SIZE, + fields: params.body.field_definitions.map((field) => field.name), + _source: false, + }; + + // This gives us a "fields" representation rather than _source from the simulation + const runtimeFieldsResult = await scopedClusterClient.asCurrentUser.search({ + index: params.path.id, + ...runtimeFieldsSearchBody, + }); + + return { + status: 'success', + simulationError: null, + documentsWithRuntimeFieldsApplied: runtimeFieldsResult.hits.hits + .map((hit) => { + if (!hit.fields) { + return {}; + } + return Object.keys(hit.fields).reduce>((acc, field) => { + acc[field] = hit.fields![field][0]; + return acc; + }, {}); + }) + .filter((doc) => Object.keys(doc).length > 0), + }; + } catch (e) { + if (e instanceof DefinitionNotFound) { + throw notFound(e); + } + + throw internal(e); + } + }, +}); diff --git a/x-pack/solutions/observability/plugins/streams/server/routes/streams/schema/unmapped_fields.ts b/x-pack/solutions/observability/plugins/streams/server/routes/streams/schema/unmapped_fields.ts new file mode 100644 index 0000000000000..15bcb964b8fd6 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams/server/routes/streams/schema/unmapped_fields.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod'; +import { internal, notFound } from '@hapi/boom'; +import { getFlattenedObject } from '@kbn/std'; +import { DefinitionNotFound } from '../../../lib/streams/errors'; +import { checkReadAccess, readAncestors, readStream } from '../../../lib/streams/stream_crud'; +import { createServerRoute } from '../../create_server_route'; + +const SAMPLE_SIZE = 500; + +export const unmappedFieldsRoute = createServerRoute({ + endpoint: 'GET /api/streams/{id}/schema/unmapped_fields', + options: { + access: 'internal', + }, + security: { + authz: { + enabled: false, + reason: + 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', + }, + }, + params: z.object({ + path: z.object({ id: z.string() }), + }), + handler: async ({ + response, + params, + request, + logger, + getScopedClients, + }): Promise<{ unmappedFields: string[] }> => { + try { + const { scopedClusterClient } = await getScopedClients({ request }); + + const hasAccess = await checkReadAccess({ id: params.path.id, scopedClusterClient }); + if (!hasAccess) { + throw new DefinitionNotFound(`Stream definition for ${params.path.id} not found.`); + } + + const streamEntity = await readStream({ + scopedClusterClient, + id: params.path.id, + }); + + const searchBody = { + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + size: SAMPLE_SIZE, + }; + + const results = await scopedClusterClient.asCurrentUser.search({ + index: params.path.id, + ...searchBody, + }); + + const sourceFields = new Set(); + + results.hits.hits.forEach((hit) => { + Object.keys(getFlattenedObject(hit._source as Record)).forEach((field) => { + sourceFields.add(field); + }); + }); + + // Mapped fields from the stream's definition and inherited from ancestors + const mappedFields = new Set(); + + streamEntity.definition.fields.forEach((field) => mappedFields.add(field.name)); + + const { ancestors } = await readAncestors({ + id: params.path.id, + scopedClusterClient, + }); + + for (const ancestor of ancestors) { + ancestor.definition.fields.forEach((field) => mappedFields.add(field.name)); + } + + const unmappedFields = Array.from(sourceFields) + .filter((field) => !mappedFields.has(field)) + .sort(); + + return { unmappedFields }; + } catch (e) { + if (e instanceof DefinitionNotFound) { + throw notFound(e); + } + + throw internal(e); + } + }, +}); diff --git a/x-pack/solutions/observability/plugins/streams/tsconfig.json b/x-pack/solutions/observability/plugins/streams/tsconfig.json index 08ed4e1648af7..fbb8515998fb3 100644 --- a/x-pack/solutions/observability/plugins/streams/tsconfig.json +++ b/x-pack/solutions/observability/plugins/streams/tsconfig.json @@ -30,6 +30,7 @@ "@kbn/server-route-repository-client", "@kbn/observability-utils-server", "@kbn/observability-utils-common", + "@kbn/std", "@kbn/safer-lodash-set" ] } diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/preview_table/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/preview_table/index.tsx index 22db6ff294079..d200e6b3b40be 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/preview_table/index.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/preview_table/index.tsx @@ -8,7 +8,13 @@ import { EuiDataGrid } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect, useMemo, useState } from 'react'; -export function PreviewTable({ documents }: { documents: unknown[] }) { +export function PreviewTable({ + documents, + displayColumns, +}: { + documents: unknown[]; + displayColumns?: string[]; +}) { const [height, setHeight] = useState('100px'); useEffect(() => { // set height to 100% after a short delay otherwise it doesn't calculate correctly @@ -19,6 +25,8 @@ export function PreviewTable({ documents }: { documents: unknown[] }) { }, []); const columns = useMemo(() => { + if (displayColumns) return displayColumns; + const cols = new Set(); documents.forEach((doc) => { if (!doc || typeof doc !== 'object') { @@ -29,7 +37,7 @@ export function PreviewTable({ documents }: { documents: unknown[] }) { }); }); return Array.from(cols); - }, [documents]); + }, [displayColumns, documents]); const gridColumns = useMemo(() => { return Array.from(columns).map((column) => ({ diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_management/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_management/index.tsx index c093f05c03210..24567fe8d80a3 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_management/index.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_management/index.tsx @@ -12,9 +12,11 @@ import { ClassicStreamDetailManagement } from './classic'; export function StreamDetailManagement({ definition, refreshDefinition, + isLoadingDefinition, }: { definition?: ReadStreamDefinition; refreshDefinition: () => void; + isLoadingDefinition: boolean; }) { if (!definition) { return null; @@ -22,7 +24,11 @@ export function StreamDetailManagement({ if (definition.managed) { return ( - + ); } diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_management/wired.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_management/wired.tsx index 5f8c4e57bf7d1..6b4888c8d4668 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_management/wired.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_management/wired.tsx @@ -23,9 +23,11 @@ function isValidManagementSubTab(value: string): value is ManagementSubTabs { export function WiredStreamDetailManagement({ definition, refreshDefinition, + isLoadingDefinition, }: { definition?: ReadStreamDefinition; refreshDefinition: () => void; + isLoadingDefinition: boolean; }) { const { path: { key, subtab }, @@ -50,7 +52,11 @@ export function WiredStreamDetailManagement({ }, schemaEditor: { content: ( - + ), label: i18n.translate('xpack.streams.streamDetailView.schemaEditorTab', { defaultMessage: 'Schema editor', diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_overview/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_overview/index.tsx index c051d114317ea..28af5f4f104c1 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_overview/index.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_overview/index.tsx @@ -8,15 +8,15 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import { calculateAuto } from '@kbn/calculate-auto'; import { i18n } from '@kbn/i18n'; import { useDateRange } from '@kbn/observability-utils-browser/hooks/use_date_range'; -import { StreamDefinition } from '@kbn/streams-plugin/common'; import moment from 'moment'; import React, { useMemo } from 'react'; +import { ReadStreamDefinition } from '@kbn/streams-plugin/common'; import { useKibana } from '../../hooks/use_kibana'; import { useStreamsAppFetch } from '../../hooks/use_streams_app_fetch'; import { ControlledEsqlChart } from '../esql_chart/controlled_esql_chart'; import { StreamsAppSearchBar } from '../streams_app_search_bar'; -export function StreamDetailOverview({ definition }: { definition?: StreamDefinition }) { +export function StreamDetailOverview({ definition }: { definition?: ReadStreamDefinition }) { const { dependencies: { start: { diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_routing/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_routing/index.tsx index ca58051f9db2b..2b829aca37b86 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_routing/index.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_routing/index.tsx @@ -78,7 +78,12 @@ export function StreamDetailRouting({ const closeModal = () => routingAppState.setShowDeleteModal(false); return ( - <> + {routingAppState.showDeleteModal && routingAppState.childUnderEdit && ( - + ); } diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_parent.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_parent.tsx new file mode 100644 index 0000000000000..5f8b6f4af0ffe --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_parent.tsx @@ -0,0 +1,36 @@ +/* + * 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 { EuiBadge } from '@elastic/eui'; +import React from 'react'; +import { useStreamsAppRouter } from '../../hooks/use_streams_app_router'; + +export const FieldParent = ({ + parent, + linkEnabled = false, +}: { + parent: string; + linkEnabled?: boolean; +}) => { + const router = useStreamsAppRouter(); + return linkEnabled ? ( + + {parent} + + ) : ( + {parent} + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_status.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_status.tsx new file mode 100644 index 0000000000000..dda456a9f49f7 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_status.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export type FieldStatus = 'inherited' | 'mapped' | 'unmapped'; + +export const FIELD_STATUS_MAP = { + inherited: { + color: 'hollow', + label: i18n.translate('xpack.streams.streamDetailSchemaEditorInheritedStatusLabel', { + defaultMessage: 'Inherited', + }), + }, + mapped: { + color: 'success', + label: i18n.translate('xpack.streams.streamDetailSchemaEditorMappedStatusLabel', { + defaultMessage: 'Mapped', + }), + }, + unmapped: { + color: 'default', + label: i18n.translate('xpack.streams.streamDetailSchemaEditorUnmappedStatusLabel', { + defaultMessage: 'Unmapped', + }), + }, +}; + +export const FieldStatus = ({ status }: { status: FieldStatus }) => { + return ( + <> + {FIELD_STATUS_MAP[status].label} + + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_type.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_type.tsx new file mode 100644 index 0000000000000..a278d22dcd3ec --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_type.tsx @@ -0,0 +1,67 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiToken } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FieldDefinition } from '@kbn/streams-plugin/common/types'; +import React from 'react'; + +export const FIELD_TYPE_MAP = { + boolean: { + icon: 'tokenBoolean', + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableBooleanType', { + defaultMessage: 'Boolean', + }), + }, + date: { + icon: 'tokenDate', + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableDateType', { + defaultMessage: 'Date', + }), + }, + keyword: { + icon: 'tokenKeyword', + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableKeywordType', { + defaultMessage: 'Keyword', + }), + }, + match_only_text: { + icon: 'tokenText', + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableTextType', { + defaultMessage: 'Text', + }), + }, + long: { + icon: 'tokenNumber', + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableNumberType', { + defaultMessage: 'Number', + }), + }, + double: { + icon: 'tokenNumber', + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableNumberType', { + defaultMessage: 'Number', + }), + }, + ip: { + icon: 'tokenIP', + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableIpType', { + defaultMessage: 'IP', + }), + }, +}; + +export const FieldType = ({ type }: { type: FieldDefinition['type'] }) => { + return ( + + + + + {`${FIELD_TYPE_MAP[type].label}`} + + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/fields_table.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/fields_table.tsx new file mode 100644 index 0000000000000..b50fdee7e6ae9 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/fields_table.tsx @@ -0,0 +1,355 @@ +/* + * 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 { + EuiButtonIcon, + EuiContextMenu, + EuiDataGrid, + EuiPopover, + EuiSearchBar, + useGeneratedHtmlId, +} from '@elastic/eui'; +import type { + EuiContextMenuPanelDescriptor, + EuiContextMenuPanelItemDescriptor, + EuiDataGridProps, + Query, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import useToggle from 'react-use/lib/useToggle'; +import { ReadStreamDefinition } from '@kbn/streams-plugin/common/types'; +import { FieldType } from './field_type'; +import { FieldStatus } from './field_status'; +import { FieldEntry, SchemaEditorEditingState } from './hooks/use_editing_state'; +import { SchemaEditorUnpromotingState } from './hooks/use_unpromoting_state'; +import { FieldParent } from './field_parent'; + +interface FieldsTableContainerProps { + definition: ReadStreamDefinition; + unmappedFieldsResult?: string[]; + isLoadingUnmappedFields: boolean; + query?: Query; + editingState: SchemaEditorEditingState; + unpromotingState: SchemaEditorUnpromotingState; +} + +const COLUMNS = { + name: { + display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTablenameHeader', { + defaultMessage: 'Field', + }), + }, + type: { + display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTabletypeHeader', { + defaultMessage: 'Type', + }), + }, + format: { + display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableformatHeader', { + defaultMessage: 'Format', + }), + }, + parent: { + display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableFieldParentHeader', { + defaultMessage: 'Field Parent (Stream)', + }), + }, + status: { + display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTablestatusHeader', { + defaultMessage: 'Status', + }), + }, +}; + +export const EMPTY_CONTENT = '-----'; + +export const FieldsTableContainer = ({ + definition, + unmappedFieldsResult, + isLoadingUnmappedFields, + query, + editingState, + unpromotingState, +}: FieldsTableContainerProps) => { + const inheritedFields = useMemo(() => { + return definition.inheritedFields.map((field) => ({ + name: field.name, + type: field.type, + format: field.format, + parent: field.from, + status: 'inherited' as const, + })); + }, [definition]); + + const filteredInheritedFields = useMemo(() => { + if (!query) return inheritedFields; + return EuiSearchBar.Query.execute(query, inheritedFields, { + defaultFields: ['name', 'type'], + }); + }, [inheritedFields, query]); + + const mappedFields = useMemo(() => { + return definition.fields.map((field) => ({ + name: field.name, + type: field.type, + format: field.format, + parent: definition.id, + status: 'mapped' as const, + })); + }, [definition]); + + const filteredMappedFields = useMemo(() => { + if (!query) return mappedFields; + return EuiSearchBar.Query.execute(query, mappedFields, { + defaultFields: ['name', 'type'], + }); + }, [mappedFields, query]); + + const unmappedFields = useMemo(() => { + return unmappedFieldsResult + ? unmappedFieldsResult.map((field) => ({ + name: field, + parent: definition.id, + status: 'unmapped' as const, + })) + : []; + }, [definition.id, unmappedFieldsResult]); + + const filteredUnmappedFields = useMemo(() => { + if (!unmappedFieldsResult) return []; + if (!query) return unmappedFields; + return EuiSearchBar.Query.execute(query, unmappedFields, { + defaultFields: ['name'], + }); + }, [unmappedFieldsResult, query, unmappedFields]); + + const allFilteredFields = useMemo(() => { + return [...filteredInheritedFields, ...filteredMappedFields, ...filteredUnmappedFields]; + }, [filteredInheritedFields, filteredMappedFields, filteredUnmappedFields]); + + return ( + + ); +}; + +interface FieldsTableProps { + definition: ReadStreamDefinition; + fields: FieldEntry[]; + editingState: SchemaEditorEditingState; + unpromotingState: SchemaEditorUnpromotingState; +} + +const FieldsTable = ({ definition, fields, editingState, unpromotingState }: FieldsTableProps) => { + const [visibleColumns, setVisibleColumns] = useState(Object.keys(COLUMNS)); + + const trailingColumns = useMemo(() => { + return [ + { + id: 'actions', + width: 40, + headerCellRender: () => null, + rowCellRender: ({ rowIndex }) => { + const field = fields[rowIndex]; + + let actions: ActionsCellActionsDescriptor[] = []; + + switch (field.status) { + case 'mapped': + actions = [ + { + name: i18n.translate('xpack.streams.actions.viewFieldLabel', { + defaultMessage: 'View field', + }), + disabled: editingState.isSaving, + onClick: (fieldEntry: FieldEntry) => { + editingState.selectField(fieldEntry, false); + }, + }, + { + name: i18n.translate('xpack.streams.actions.editFieldLabel', { + defaultMessage: 'Edit field', + }), + disabled: editingState.isSaving, + onClick: (fieldEntry: FieldEntry) => { + editingState.selectField(fieldEntry, true); + }, + }, + { + name: i18n.translate('xpack.streams.actions.unpromoteFieldLabel', { + defaultMessage: 'Unmap field', + }), + disabled: unpromotingState.isUnpromotingField, + onClick: (fieldEntry: FieldEntry) => { + unpromotingState.setSelectedField(fieldEntry.name); + }, + }, + ]; + break; + case 'unmapped': + actions = [ + { + name: i18n.translate('xpack.streams.actions.viewFieldLabel', { + defaultMessage: 'View field', + }), + disabled: editingState.isSaving, + onClick: (fieldEntry: FieldEntry) => { + editingState.selectField(fieldEntry, false); + }, + }, + { + name: i18n.translate('xpack.streams.actions.mapFieldLabel', { + defaultMessage: 'Map field', + }), + disabled: editingState.isSaving, + onClick: (fieldEntry: FieldEntry) => { + editingState.selectField(fieldEntry, true); + }, + }, + ]; + break; + case 'inherited': + actions = [ + { + name: i18n.translate('xpack.streams.actions.viewFieldLabel', { + defaultMessage: 'View field', + }), + disabled: editingState.isSaving, + onClick: (fieldEntry: FieldEntry) => { + editingState.selectField(fieldEntry, false); + }, + }, + ]; + break; + } + + return ( + ({ + name: action.name, + icon: action.icon, + onClick: (event) => { + action.onClick(field); + }, + })), + }, + ]} + /> + ); + }, + }, + ] as EuiDataGridProps['trailingControlColumns']; + }, [editingState, fields, unpromotingState]); + + return ( + ({ + id: columnId, + ...COLUMNS[columnId as keyof typeof COLUMNS], + }))} + columnVisibility={{ + visibleColumns, + setVisibleColumns, + canDragAndDropColumns: false, + }} + toolbarVisibility={false} + rowCount={fields.length} + renderCellValue={({ rowIndex, columnId }) => { + const field = fields[rowIndex]; + if (columnId === 'type') { + const fieldType = field.type; + if (!fieldType) return EMPTY_CONTENT; + return ; + } else if (columnId === 'parent') { + return ; + } else if (columnId === 'status') { + return ; + } else { + return field[columnId as keyof FieldEntry] || EMPTY_CONTENT; + } + }} + trailingControlColumns={trailingColumns} + gridStyle={{ + border: 'none', + rowHover: 'none', + header: 'underline', + }} + /> + ); +}; + +export const ActionsCell = ({ panels }: { panels: EuiContextMenuPanelDescriptor[] }) => { + const contextMenuPopoverId = useGeneratedHtmlId({ + prefix: 'fieldsTableContextMenuPopover', + }); + + const [popoverIsOpen, togglePopoverIsOpen] = useToggle(false); + + return ( + { + togglePopoverIsOpen(); + }} + /> + } + isOpen={popoverIsOpen} + closePopover={() => togglePopoverIsOpen(false)} + > + ({ + ...panel, + items: panel.items?.map((item) => ({ + name: item.name, + icon: item.icon, + onClick: (event) => { + if (item.onClick) { + item.onClick(event as any); + } + togglePopoverIsOpen(false); + }, + })), + }))} + /> + + ); +}; + +export type ActionsCellActionsDescriptor = Omit & { + onClick: (field: FieldEntry) => void; +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/children_affected_callout.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/children_affected_callout.tsx new file mode 100644 index 0000000000000..1cb9cbd2dd045 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/children_affected_callout.tsx @@ -0,0 +1,33 @@ +/* + * 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 { EuiCallOut } from '@elastic/eui'; +import { StreamDefinition } from '@kbn/streams-plugin/common/types'; +import { i18n } from '@kbn/i18n'; + +export const ChildrenAffectedCallout = ({ + childStreams, +}: { + childStreams: StreamDefinition['children']; +}) => { + return ( + + {i18n.translate('xpack.streams.childStreamsWarning.text', { + defaultMessage: "Editing this field will affect it's dependant streams: {affectedStreams} ", + values: { + affectedStreams: childStreams.map((stream) => stream.id).join(', '), + }, + })} + + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_format.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_format.tsx new file mode 100644 index 0000000000000..98f5d899c1074 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_format.tsx @@ -0,0 +1,39 @@ +/* + * 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 { EuiFieldText } from '@elastic/eui'; +import React from 'react'; +import { FieldDefinition } from '@kbn/streams-plugin/common/types'; +import { SchemaEditorEditingState } from '../hooks/use_editing_state'; + +type FieldFormFormatProps = Pick< + SchemaEditorEditingState, + 'nextFieldType' | 'nextFieldFormat' | 'setNextFieldFormat' +>; + +export const typeSupportsFormat = (type?: FieldDefinition['type']) => { + if (!type) return false; + return ['date'].includes(type); +}; + +export const FieldFormFormat = ({ + nextFieldType: fieldType, + nextFieldFormat: value, + setNextFieldFormat: onChange, +}: FieldFormFormatProps) => { + if (!typeSupportsFormat(fieldType)) { + return null; + } + return ( + onChange(e.target.value)} + /> + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_type.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_type.tsx new file mode 100644 index 0000000000000..c4e601e306f1d --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_type.tsx @@ -0,0 +1,44 @@ +/* + * 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 { EuiSelect } from '@elastic/eui'; +import React from 'react'; +import { SchemaEditorEditingState } from '../hooks/use_editing_state'; + +type FieldFormTypeProps = Pick; + +const TYPE_OPTIONS = { + long: 'Long', + double: 'Double', + keyword: 'Keyword', + match_only_text: 'Text (match_only_text)', + boolean: 'Boolean', + ip: 'IP', + date: 'Date', +} as const; + +type FieldTypeOption = keyof typeof TYPE_OPTIONS; + +export const FieldFormType = ({ + nextFieldType: value, + setNextFieldType: onChange, +}: FieldFormTypeProps) => { + return ( + { + onChange(event.target.value as FieldTypeOption); + }} + value={value} + options={Object.entries(TYPE_OPTIONS).map(([optionKey, optionValue]) => ({ + text: optionValue, + value: optionKey, + }))} + /> + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_summary.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_summary.tsx new file mode 100644 index 0000000000000..796e7531258d3 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_summary.tsx @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIconTip, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { useStreamsAppRouter } from '../../../hooks/use_streams_app_router'; +import { FieldParent } from '../field_parent'; +import { FieldStatus } from '../field_status'; +import { FieldFormFormat, typeSupportsFormat } from './field_form_format'; +import { FieldFormType } from './field_form_type'; +import { SchemaEditorFlyoutProps } from '.'; +import { FieldType } from '../field_type'; + +const EMPTY_CONTENT = '-----'; + +const title = i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryTitle', { + defaultMessage: 'Field summary', +}); + +const FIELD_SUMMARIES = { + fieldStatus: { + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldNameHeader', { + defaultMessage: 'Status', + }), + }, + fieldType: { + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldTypeHeader', { + defaultMessage: 'Type', + }), + }, + fieldFormat: { + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldFormatHeader', { + defaultMessage: 'Format', + }), + }, + fieldParent: { + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldParentHeader', { + defaultMessage: 'Field Parent', + }), + }, +}; + +export const FieldSummary = (props: SchemaEditorFlyoutProps) => { + const { + selectedField, + isEditing, + nextFieldType, + setNextFieldType, + nextFieldFormat, + setNextFieldFormat, + toggleIsEditing, + } = props; + + const router = useStreamsAppRouter(); + + if (!selectedField) { + return null; + } + + return ( + + + + + {title} + + + {selectedField.status !== 'inherited' && !isEditing ? ( + + + + toggleIsEditing()} + iconType="pencil" + > + {i18n.translate('xpack.streams.fieldSummary.editButtonLabel', { + defaultMessage: 'Edit', + })} + + + + + ) : selectedField.status === 'inherited' ? ( + + + + + {i18n.translate('xpack.streams.fieldSummary.editInParentButtonLabel', { + defaultMessage: 'Edit in parent stream', + })} + + + + + ) : null} + + + + + + + + + {FIELD_SUMMARIES.fieldStatus.label}{' '} + + + + + + + + + + + + + + + + + + + {FIELD_SUMMARIES.fieldType.label} + + + + {isEditing ? ( + + ) : selectedField.type ? ( + + ) : ( + `${EMPTY_CONTENT}` + )} + + + + + + {typeSupportsFormat(nextFieldType) && ( + <> + + + + {FIELD_SUMMARIES.fieldFormat.label} + + + + {isEditing ? ( + + ) : ( + `${selectedField.format ?? EMPTY_CONTENT}` + )} + + + + + )} + + + + + {FIELD_SUMMARIES.fieldParent.label} + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/index.tsx new file mode 100644 index 0000000000000..8bbdd6abf9ad3 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/index.tsx @@ -0,0 +1,104 @@ +/* + * 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 { StreamsRepositoryClient } from '@kbn/streams-plugin/public/api'; +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiFlyoutFooter, + EuiTitle, + EuiButton, +} from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { ReadStreamDefinition } from '@kbn/streams-plugin/common'; +import { SchemaEditorEditingState } from '../hooks/use_editing_state'; +import { ChildrenAffectedCallout } from './children_affected_callout'; +import { SamplePreviewTable } from './sample_preview_table'; +import { FieldSummary } from './field_summary'; + +export type SchemaEditorFlyoutProps = { + streamsRepositoryClient: StreamsRepositoryClient; + definition: ReadStreamDefinition; +} & SchemaEditorEditingState; + +export const SchemaEditorFlyout = (props: SchemaEditorFlyoutProps) => { + const { + definition, + streamsRepositoryClient, + selectedField, + reset, + nextFieldDefinition, + isEditing, + isSaving, + saveChanges, + } = props; + + return ( + reset()} maxWidth="500px"> + + + + +

{selectedField?.name}

+
+
+
+
+ + + + + {isEditing && definition.children.length > 0 ? ( + + + + ) : null} + + + + + + + + + + reset()} + flush="left" + > + {i18n.translate('xpack.streams.schemaEditorFlyout.closeButtonLabel', { + defaultMessage: 'Cancel', + })} + + + + saveChanges && saveChanges()} + > + {i18n.translate('xpack.streams.fieldForm.saveButtonLabel', { + defaultMessage: 'Save changes', + })} + + + + +
+ ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/sample_preview_table.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/sample_preview_table.tsx new file mode 100644 index 0000000000000..8c04a0b70e3be --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/sample_preview_table.tsx @@ -0,0 +1,116 @@ +/* + * 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 { StreamsRepositoryClient } from '@kbn/streams-plugin/public/api'; +import { ReadStreamDefinition } from '@kbn/streams-plugin/common'; +import { FieldDefinition } from '@kbn/streams-plugin/common/types'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { EuiCallOut } from '@elastic/eui'; +import { getFormattedError } from '../../../util/errors'; +import { useStreamsAppFetch } from '../../../hooks/use_streams_app_fetch'; +import { PreviewTable } from '../../preview_table'; +import { isFullFieldDefinition } from '../hooks/use_editing_state'; +import { LoadingPanel } from '../../loading_panel'; + +interface SamplePreviewTableProps { + definition: ReadStreamDefinition; + nextFieldDefinition?: Partial; + streamsRepositoryClient: StreamsRepositoryClient; +} + +export const SamplePreviewTable = (props: SamplePreviewTableProps) => { + const { nextFieldDefinition, ...rest } = props; + if (isFullFieldDefinition(nextFieldDefinition)) { + return ; + } else { + return null; + } +}; + +const SAMPLE_DOCUMENTS_TO_SHOW = 20; + +const SamplePreviewTableContent = ({ + definition, + nextFieldDefinition, + streamsRepositoryClient, +}: SamplePreviewTableProps & { nextFieldDefinition: FieldDefinition }) => { + const { value, loading, error } = useStreamsAppFetch( + ({ signal }) => { + return streamsRepositoryClient.fetch('POST /api/streams/{id}/schema/fields_simulation', { + signal, + params: { + path: { + id: definition.id, + }, + body: { + field_definitions: [nextFieldDefinition], + }, + }, + }); + }, + [definition.id, nextFieldDefinition, streamsRepositoryClient], + { + disableToastOnError: true, + } + ); + + const columns = useMemo(() => { + return [nextFieldDefinition.name]; + }, [nextFieldDefinition.name]); + + if (loading) { + return ; + } + + if ( + value && + (value.status === 'unknown' || value.documentsWithRuntimeFieldsApplied?.length === 0) + ) { + return ( + + {i18n.translate('xpack.streams.samplePreviewTable.unknownStatus', { + defaultMessage: + "Couldn't simulate changes due to a lack of indexed documents with this field", + })} + + ); + } + + if ((value && value.status === 'failure') || error) { + const formattedError = getFormattedError(error); + return ( + + {value?.simulationError ?? formattedError?.message} + + ); + } + + if (value && value.status === 'success' && value.documentsWithRuntimeFieldsApplied) { + return ( +
+ +
+ ); + } + + return null; +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_editing_state.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_editing_state.tsx new file mode 100644 index 0000000000000..9fc6288c1daf7 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_editing_state.tsx @@ -0,0 +1,180 @@ +/* + * 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 { FieldDefinition, ReadStreamDefinition } from '@kbn/streams-plugin/common/types'; +import { StreamsRepositoryClient } from '@kbn/streams-plugin/public/api'; +import { useCallback, useMemo, useState } from 'react'; +import useToggle from 'react-use/lib/useToggle'; +import { useAbortController } from '@kbn/observability-utils-browser/hooks/use_abort_controller'; +import { ToastsStart } from '@kbn/core-notifications-browser'; +import { i18n } from '@kbn/i18n'; +import { FieldStatus } from '../field_status'; + +export type SchemaEditorEditingState = ReturnType; + +export interface FieldEntry { + name: FieldDefinition['name']; + type?: FieldDefinition['type']; + format?: FieldDefinition['format']; + parent: string; + status: FieldStatus; +} + +export type EditableFieldDefinition = FieldEntry; + +export const useEditingState = ({ + streamsRepositoryClient, + definition, + refreshDefinition, + refreshUnmappedFields, + toastsService, +}: { + streamsRepositoryClient: StreamsRepositoryClient; + definition: ReadStreamDefinition; + refreshDefinition: () => void; + refreshUnmappedFields: () => void; + toastsService: ToastsStart; +}) => { + const abortController = useAbortController(); + /* Whether the field is being edited, otherwise it's just displayed. */ + const [isEditing, toggleIsEditing] = useToggle(false); + /* Whether changes are being persisted */ + const [isSaving, toggleIsSaving] = useToggle(false); + /* Holds errors from saving changes */ + const [error, setError] = useState(); + + /* Represents the currently selected field. This should not be edited directly. */ + const [selectedField, setSelectedField] = useState(); + + /** Dirty state */ + /* Dirty state of the field type */ + const [nextFieldType, setNextFieldType] = useState(); + /* Dirty state of the field format */ + const [nextFieldFormat, setNextFieldFormat] = useState< + EditableFieldDefinition['format'] | undefined + >(); + /* Full dirty definition entry that can be persisted against a stream */ + const nextFieldDefinition = useMemo(() => { + return selectedField + ? { + name: selectedField.name, + type: nextFieldType, + ...(nextFieldFormat && nextFieldType === 'date' ? { format: nextFieldFormat } : {}), + } + : undefined; + }, [nextFieldFormat, nextFieldType, selectedField]); + + const selectField = useCallback( + (field: EditableFieldDefinition, selectAndEdit?: boolean) => { + setSelectedField(field); + setNextFieldType(field.type); + setNextFieldFormat(field.format); + toggleIsEditing(selectAndEdit !== undefined ? selectAndEdit : false); + }, + [toggleIsEditing] + ); + + const reset = useCallback(() => { + setSelectedField(undefined); + setNextFieldType(undefined); + setNextFieldFormat(undefined); + toggleIsEditing(false); + toggleIsSaving(false); + setError(undefined); + }, [toggleIsEditing, toggleIsSaving]); + + const saveChanges = useMemo(() => { + return selectedField && + isFullFieldDefinition(nextFieldDefinition) && + hasChanges(selectedField, nextFieldDefinition) + ? async () => { + toggleIsSaving(true); + try { + await streamsRepositoryClient.fetch(`PUT /api/streams/{id}`, { + signal: abortController.signal, + params: { + path: { + id: definition.id, + }, + body: { + processing: definition.processing, + children: definition.children, + fields: [ + ...definition.fields.filter((field) => field.name !== nextFieldDefinition.name), + nextFieldDefinition, + ], + }, + }, + }); + toastsService.addSuccess( + i18n.translate('xpack.streams.streamDetailSchemaEditorEditSuccessToast', { + defaultMessage: '{field} was successfully edited', + values: { field: nextFieldDefinition.name }, + }) + ); + reset(); + refreshDefinition(); + refreshUnmappedFields(); + } catch (e) { + toggleIsSaving(false); + setError(e); + toastsService.addError(e, { + title: i18n.translate('xpack.streams.streamDetailSchemaEditorEditErrorToast', { + defaultMessage: 'Something went wrong editing the {field} field', + values: { field: nextFieldDefinition.name }, + }), + }); + } + } + : undefined; + }, [ + abortController.signal, + definition.children, + definition.fields, + definition.id, + definition.processing, + nextFieldDefinition, + refreshDefinition, + refreshUnmappedFields, + reset, + selectedField, + streamsRepositoryClient, + toastsService, + toggleIsSaving, + ]); + + return { + selectedField, + selectField, + isEditing, + toggleIsEditing, + nextFieldType, + setNextFieldType, + nextFieldFormat, + setNextFieldFormat, + isSaving, + saveChanges, + reset, + error, + nextFieldDefinition, + }; +}; + +export const isFullFieldDefinition = ( + value?: Partial +): value is FieldDefinition => { + return !!value && !!value.name && !!value.type; +}; + +const hasChanges = ( + selectedField: Partial, + nextFieldEntry: Partial +) => { + return ( + selectedField.type !== nextFieldEntry.type || selectedField.format !== nextFieldEntry.format + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_unpromoting_state.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_unpromoting_state.tsx new file mode 100644 index 0000000000000..b6e30c87cd7b4 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_unpromoting_state.tsx @@ -0,0 +1,93 @@ +/* + * 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 { StreamsRepositoryClient } from '@kbn/streams-plugin/public/api'; +import { useCallback, useState } from 'react'; +import useToggle from 'react-use/lib/useToggle'; +import { useAbortController } from '@kbn/observability-utils-browser/hooks/use_abort_controller'; +import { ToastsStart } from '@kbn/core-notifications-browser'; +import { i18n } from '@kbn/i18n'; +import { ReadStreamDefinition } from '@kbn/streams-plugin/common'; + +export type SchemaEditorUnpromotingState = ReturnType; + +export const useUnpromotingState = ({ + streamsRepositoryClient, + definition, + refreshDefinition, + refreshUnmappedFields, + toastsService, +}: { + streamsRepositoryClient: StreamsRepositoryClient; + definition: ReadStreamDefinition; + refreshDefinition: () => void; + refreshUnmappedFields: () => void; + toastsService: ToastsStart; +}) => { + const abortController = useAbortController(); + /* Represents the currently persisted state of the selected field. This should not be edited directly. */ + const [selectedField, setSelectedField] = useState(); + /* Whether changes are being persisted */ + const [isUnpromotingField, toggleIsUnpromotingField] = useToggle(false); + /* Holds errors from saving changes */ + const [error, setError] = useState(); + + const unpromoteField = useCallback(async () => { + if (!selectedField) { + return; + } + toggleIsUnpromotingField(true); + try { + await streamsRepositoryClient.fetch(`PUT /api/streams/{id}`, { + signal: abortController.signal, + params: { + path: { + id: definition.id, + }, + body: { + processing: definition.processing, + children: definition.children, + fields: definition.fields.filter((field) => field.name !== selectedField), + }, + }, + }); + toggleIsUnpromotingField(false); + setSelectedField(undefined); + refreshDefinition(); + refreshUnmappedFields(); + toastsService.addSuccess( + i18n.translate('xpack.streams.streamDetailSchemaEditorUnmapSuccessToast', { + defaultMessage: '{field} was successfully unmapped', + values: { field: selectedField }, + }) + ); + } catch (e) { + toggleIsUnpromotingField(false); + setError(e); + toastsService.addError(e, { + title: i18n.translate('xpack.streams.streamDetailSchemaEditorUnmapErrorToast', { + defaultMessage: 'Something went wrong unmapping the {field} field', + values: { field: selectedField }, + }), + }); + } + }, [ + abortController.signal, + definition.children, + definition.fields, + definition.id, + definition.processing, + refreshDefinition, + refreshUnmappedFields, + selectedField, + streamsRepositoryClient, + toastsService, + toggleIsUnpromotingField, + ]); + + return { selectedField, setSelectedField, isUnpromotingField, unpromoteField, error }; +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/index.tsx index 7d3e9322e8d4f..3ca410e74ffdb 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/index.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/index.tsx @@ -4,15 +4,138 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { StreamDefinition } from '@kbn/streams-plugin/common'; -import React from 'react'; - -export function StreamDetailSchemaEditor({ - definition: _definition, - refreshDefinition: _refreshDefinition, -}: { - definition?: StreamDefinition; +import React, { useEffect, useState } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiProgress, + EuiSearchBar, + EuiPortal, + Query, +} from '@elastic/eui'; +import { ReadStreamDefinition } from '@kbn/streams-plugin/common'; +import { css } from '@emotion/css'; +import { useEditingState } from './hooks/use_editing_state'; +import { SchemaEditorFlyout } from './flyout'; +import { useKibana } from '../../hooks/use_kibana'; +import { useUnpromotingState } from './hooks/use_unpromoting_state'; +import { SimpleSearchBar } from './simple_search_bar'; +import { UnpromoteFieldModal } from './unpromote_field_modal'; +import { useStreamsAppFetch } from '../../hooks/use_streams_app_fetch'; +import { FieldsTableContainer } from './fields_table'; + +interface SchemaEditorProps { + definition?: ReadStreamDefinition; refreshDefinition: () => void; -}) { - return <>{'TODO'}; + isLoadingDefinition: boolean; +} + +export function StreamDetailSchemaEditor(props: SchemaEditorProps) { + if (!props.definition) return null; + return ; } + +const Content = ({ + definition, + refreshDefinition, + isLoadingDefinition, +}: Required) => { + const { + dependencies: { + start: { + streams: { streamsRepositoryClient }, + }, + }, + core: { + notifications: { toasts }, + }, + } = useKibana(); + + const [query, setQuery] = useState(EuiSearchBar.Query.MATCH_ALL); + + const { + value: unmappedFieldsValue, + loading: isLoadingUnmappedFields, + refresh: refreshUnmappedFields, + } = useStreamsAppFetch( + ({ signal }) => { + return streamsRepositoryClient.fetch('GET /api/streams/{id}/schema/unmapped_fields', { + signal, + params: { + path: { + id: definition.id, + }, + }, + }); + }, + [definition.id, streamsRepositoryClient] + ); + + const editingState = useEditingState({ + definition, + streamsRepositoryClient, + refreshDefinition, + refreshUnmappedFields, + toastsService: toasts, + }); + + const unpromotingState = useUnpromotingState({ + definition, + streamsRepositoryClient, + refreshDefinition, + refreshUnmappedFields, + toastsService: toasts, + }); + + const { reset } = editingState; + + // If the definition changes (e.g. navigating to parent stream), reset the entire editing state. + useEffect(() => { + reset(); + }, [definition.id, reset]); + + return ( + + + {isLoadingDefinition || isLoadingUnmappedFields ? ( + + + + ) : null} + + setQuery(nextQuery.query ?? undefined)} + /> + + + + + + {editingState.selectedField && ( + + )} + + {unpromotingState.selectedField && ( + + )} + + + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/simple_search_bar.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/simple_search_bar.tsx new file mode 100644 index 0000000000000..93e972c4b999a --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/simple_search_bar.tsx @@ -0,0 +1,30 @@ +/* + * 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 { EuiSearchBar, EuiSearchBarProps } from '@elastic/eui'; +import React from 'react'; + +/* Simple search bar that doesn't attempt to integrate with unified search */ +export const SimpleSearchBar = ({ + query, + onChange, +}: { + query: EuiSearchBarProps['query']; + onChange: Required['onChange']; +}) => { + return ( + { + onChange(nextQuery); + }} + /> + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/unpromote_field_modal.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/unpromote_field_modal.tsx new file mode 100644 index 0000000000000..59d66b44eec44 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/unpromote_field_modal.tsx @@ -0,0 +1,58 @@ +/* + * 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, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { SchemaEditorUnpromotingState } from './hooks/use_unpromoting_state'; + +export const UnpromoteFieldModal = ({ + unpromotingState, +}: { + unpromotingState: SchemaEditorUnpromotingState; +}) => { + const { setSelectedField, selectedField, unpromoteField, isUnpromotingField } = unpromotingState; + + const modalTitleId = useGeneratedHtmlId(); + + if (!selectedField) return null; + + return ( + setSelectedField(undefined)}> + + {selectedField} + + + + {i18n.translate('xpack.streams.unpromoteFieldModal.unpromoteFieldWarning', { + defaultMessage: 'Are you sure you want to unmap this field from template mappings?', + })} + + + + unpromoteField()} + disabled={isUnpromotingField} + color="danger" + fill + > + {i18n.translate('xpack.streams.unpromoteFieldModal.unpromoteFieldButtonLabel', { + defaultMessage: 'Unmap field', + })} + + + + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_view/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_view/index.tsx index b0a2307f7b2b7..9ebc5a92f54db 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_view/index.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_view/index.tsx @@ -29,7 +29,11 @@ export function StreamDetailView() { }, } = useKibana(); - const { value: streamEntity, refresh } = useStreamsAppFetch( + const { + value: streamEntity, + refresh, + loading, + } = useStreamsAppFetch( ({ signal }) => { return streamsRepositoryClient.fetch('GET /api/streams/{id}', { signal, @@ -58,7 +62,13 @@ export function StreamDetailView() { }, { name: 'management', - content: , + content: ( + + ), label: i18n.translate('xpack.streams.streamDetailView.managementTab', { defaultMessage: 'Management', }), diff --git a/x-pack/solutions/observability/plugins/streams_app/public/util/errors.ts b/x-pack/solutions/observability/plugins/streams_app/public/util/errors.ts new file mode 100644 index 0000000000000..e215ad1bb7f97 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/util/errors.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getFormattedError = (error?: Error) => { + if ( + error && + 'body' in error && + typeof error.body === 'object' && + !!error.body && + 'message' in error.body && + typeof error.body.message === 'string' + ) { + return new Error(error.body.message); + } +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/tsconfig.json b/x-pack/solutions/observability/plugins/streams_app/tsconfig.json index b1184bebbe2bd..7a77dae1922d0 100644 --- a/x-pack/solutions/observability/plugins/streams_app/tsconfig.json +++ b/x-pack/solutions/observability/plugins/streams_app/tsconfig.json @@ -36,5 +36,6 @@ "@kbn/code-editor", "@kbn/ui-theme", "@kbn/navigation-plugin", + "@kbn/core-notifications-browser", ] }