diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx index 037997bbd5c86..d6bab939c1efc 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx @@ -673,49 +673,59 @@ export const QueryBarTopRow = React.memo( } function renderQueryInput() { + const filterButtonGroup = !renderFilterMenuOnly() && renderFilterButtonGroup(); + const queryInput = shouldRenderQueryInput() && ( + + + + ); + if (isQueryLangSelected || (!filterButtonGroup && !queryInput)) { + return null; + } return ( - - {!renderFilterMenuOnly() && renderFilterButtonGroup()} - {shouldRenderQueryInput() && ( - - - - )} - + + + {filterButtonGroup} + {queryInput} + + ); } @@ -787,12 +797,7 @@ export const QueryBarTopRow = React.memo( adHocDataview={props.indexPatterns?.[0]} /> )} - - {!isQueryLangSelected ? renderQueryInput() : null} - + {renderQueryInput()} {props.renderQueryInputAppend?.()} {shouldShowDatePickerAsBadge() && props.filterBar} {renderUpdateButton()} diff --git a/x-pack/plugins/streams/common/index.ts b/x-pack/plugins/streams/common/index.ts index 3a7306e46cae2..634994cb87f13 100644 --- a/x-pack/plugins/streams/common/index.ts +++ b/x-pack/plugins/streams/common/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export type { StreamDefinition } from './types'; +export type { StreamDefinition, ReadStreamDefinition } from './types'; diff --git a/x-pack/plugins/streams/common/types.ts b/x-pack/plugins/streams/common/types.ts index c2d99d4ba1d89..59cdd1cf9c4b9 100644 --- a/x-pack/plugins/streams/common/types.ts +++ b/x-pack/plugins/streams/common/types.ts @@ -33,11 +33,11 @@ export interface AndCondition { and: Condition[]; } -export interface RerouteOrCondition { +export interface OrCondition { or: Condition[]; } -export type Condition = FilterCondition | AndCondition | RerouteOrCondition | undefined; +export type Condition = FilterCondition | AndCondition | OrCondition | undefined; export const conditionSchema: z.ZodType = z.lazy(() => z.union([ @@ -77,17 +77,17 @@ export const fieldDefinitionSchema = z.object({ export type FieldDefinition = z.infer; +export const streamChildSchema = z.object({ + id: z.string(), + condition: z.optional(conditionSchema), +}); + +export type StreamChild = z.infer; + export const streamWithoutIdDefinitonSchema = z.object({ processing: z.array(processingDefinitionSchema).default([]), fields: z.array(fieldDefinitionSchema).default([]), - children: z - .array( - z.object({ - id: z.string(), - condition: z.optional(conditionSchema), - }) - ) - .default([]), + children: z.array(streamChildSchema).default([]), }); export type StreamWithoutIdDefinition = z.infer; @@ -110,3 +110,9 @@ export type StreamDefinition = z.infer; export const streamDefinitonWithoutChildrenSchema = streamDefinitonSchema.omit({ children: true }); export type StreamWithoutChildrenDefinition = z.infer; + +export const readStreamDefinitonSchema = streamDefinitonSchema.extend({ + inheritedFields: z.array(fieldDefinitionSchema.extend({ from: z.string() })).default([]), +}); + +export type ReadStreamDefinition = z.infer; diff --git a/x-pack/plugins/streams/server/lib/streams/helpers/condition_fields.ts b/x-pack/plugins/streams/server/lib/streams/helpers/condition_fields.ts new file mode 100644 index 0000000000000..48b06b8ea0701 --- /dev/null +++ b/x-pack/plugins/streams/server/lib/streams/helpers/condition_fields.ts @@ -0,0 +1,85 @@ +/* + * 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 { Condition, FilterCondition } from '../../../../common/types'; +import { isAndCondition, isFilterCondition, isOrCondition } from './condition_guards'; + +export function isComplete(condition: Condition): boolean { + if (isFilterCondition(condition)) { + return condition.field !== undefined && condition.field !== ''; + } + if (isAndCondition(condition)) { + return condition.and.every(isComplete); + } + if (isOrCondition(condition)) { + return condition.or.every(isComplete); + } + return false; +} + +export function getFields( + condition: Condition +): Array<{ name: string; type: 'number' | 'string' }> { + const fields = collectFields(condition); + // deduplicate fields, if mapped as string and number, keep as number + const uniqueFields = new Map(); + fields.forEach((field) => { + const existing = uniqueFields.get(field.name); + if (existing === 'number') { + return; + } + if (existing === 'string' && field.type === 'number') { + uniqueFields.set(field.name, 'number'); + return; + } + uniqueFields.set(field.name, field.type); + }); + + return Array.from(uniqueFields).map(([name, type]) => ({ name, type })); +} + +function collectFields(condition: Condition): Array<{ name: string; type: 'number' | 'string' }> { + if (isFilterCondition(condition)) { + return [{ name: condition.field, type: getFieldTypeForFilterCondition(condition) }]; + } + if (isAndCondition(condition)) { + return condition.and.flatMap(collectFields); + } + if (isOrCondition(condition)) { + return condition.or.flatMap(collectFields); + } + return []; +} + +function getFieldTypeForFilterCondition(condition: FilterCondition): 'number' | 'string' { + switch (condition.operator) { + case 'gt': + case 'gte': + case 'lt': + case 'lte': + return 'number'; + case 'neq': + case 'eq': + case 'exists': + case 'contains': + case 'startsWith': + case 'endsWith': + case 'notExists': + return 'string'; + default: + return 'string'; + } +} + +export function validateCondition(condition: Condition) { + if (isFilterCondition(condition)) { + // check whether a field is specified + if (!condition.field.trim()) { + throw new Error('Field is required in conditions'); + } + } +} diff --git a/x-pack/plugins/streams/server/lib/streams/helpers/condition_guards.ts b/x-pack/plugins/streams/server/lib/streams/helpers/condition_guards.ts new file mode 100644 index 0000000000000..1469471bd8943 --- /dev/null +++ b/x-pack/plugins/streams/server/lib/streams/helpers/condition_guards.ts @@ -0,0 +1,29 @@ +/* + * 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 { + AndCondition, + conditionSchema, + FilterCondition, + filterConditionSchema, + OrCondition, +} from '../../../../common/types'; + +export function isFilterCondition(subject: any): subject is FilterCondition { + const result = filterConditionSchema.safeParse(subject); + return result.success; +} + +export function isAndCondition(subject: any): subject is AndCondition { + const result = conditionSchema.safeParse(subject); + return result.success && subject.and != null; +} + +export function isOrCondition(subject: any): subject is OrCondition { + const result = conditionSchema.safeParse(subject); + return result.success && subject.or != null; +} diff --git a/x-pack/plugins/streams/server/lib/streams/helpers/condition_to_painless.ts b/x-pack/plugins/streams/server/lib/streams/helpers/condition_to_painless.ts index dccc15b2ec8fc..1894ebaa6226d 100644 --- a/x-pack/plugins/streams/server/lib/streams/helpers/condition_to_painless.ts +++ b/x-pack/plugins/streams/server/lib/streams/helpers/condition_to_painless.ts @@ -7,30 +7,12 @@ import { isBoolean, isString } from 'lodash'; import { - AndCondition, BinaryFilterCondition, Condition, - conditionSchema, FilterCondition, - filterConditionSchema, - RerouteOrCondition, UnaryFilterCondition, } from '../../../../common/types'; - -function isFilterCondition(subject: any): subject is FilterCondition { - const result = filterConditionSchema.safeParse(subject); - return result.success; -} - -function isAndCondition(subject: any): subject is AndCondition { - const result = conditionSchema.safeParse(subject); - return result.success && subject.and != null; -} - -function isOrCondition(subject: any): subject is RerouteOrCondition { - const result = conditionSchema.safeParse(subject); - return result.success && subject.or != null; -} +import { isAndCondition, isFilterCondition, isOrCondition } from './condition_guards'; function safePainlessField(condition: FilterCondition) { return `ctx.${condition.field.split('.').join('?.')}`; diff --git a/x-pack/plugins/streams/server/lib/streams/helpers/condition_to_query_dsl.ts b/x-pack/plugins/streams/server/lib/streams/helpers/condition_to_query_dsl.ts new file mode 100644 index 0000000000000..3864639175008 --- /dev/null +++ b/x-pack/plugins/streams/server/lib/streams/helpers/condition_to_query_dsl.ts @@ -0,0 +1,63 @@ +/* + * 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 { Condition, FilterCondition } from '../../../../common/types'; +import { isAndCondition, isFilterCondition, isOrCondition } from './condition_guards'; + +function conditionToClause(condition: FilterCondition) { + switch (condition.operator) { + case 'neq': + return { bool: { must_not: { match: { [condition.field]: condition.value } } } }; + case 'eq': + return { match: { [condition.field]: condition.value } }; + case 'exists': + return { exists: { field: condition.field } }; + case 'gt': + return { range: { [condition.field]: { gt: condition.value } } }; + case 'gte': + return { range: { [condition.field]: { gte: condition.value } } }; + case 'lt': + return { range: { [condition.field]: { lt: condition.value } } }; + case 'lte': + return { range: { [condition.field]: { lte: condition.value } } }; + case 'contains': + return { wildcard: { [condition.field]: `*${condition.value}*` } }; + case 'startsWith': + return { prefix: { [condition.field]: condition.value } }; + case 'endsWith': + return { wildcard: { [condition.field]: `*${condition.value}` } }; + case 'notExists': + return { bool: { must_not: { exists: { field: condition.field } } } }; + default: + return { match_none: {} }; + } +} + +export function conditionToQueryDsl(condition: Condition): any { + if (isFilterCondition(condition)) { + return conditionToClause(condition); + } + if (isAndCondition(condition)) { + const and = condition.and.map((filter) => conditionToQueryDsl(filter)); + return { + bool: { + must: and, + }, + }; + } + if (isOrCondition(condition)) { + const or = condition.or.map((filter) => conditionToQueryDsl(filter)); + return { + bool: { + should: or, + }, + }; + } + return { + match_none: {}, + }; +} diff --git a/x-pack/plugins/streams/server/lib/streams/ingest_pipelines/logs_default_pipeline.ts b/x-pack/plugins/streams/server/lib/streams/ingest_pipelines/logs_default_pipeline.ts index 762155ba5047c..90f941657faf4 100644 --- a/x-pack/plugins/streams/server/lib/streams/ingest_pipelines/logs_default_pipeline.ts +++ b/x-pack/plugins/streams/server/lib/streams/ingest_pipelines/logs_default_pipeline.ts @@ -20,4 +20,9 @@ export const logsDefaultPipelineProcessors = [ ignore_missing_pipeline: true, }, }, + { + dot_expander: { + field: '*', + }, + }, ]; diff --git a/x-pack/plugins/streams/server/lib/streams/stream_crud.ts b/x-pack/plugins/streams/server/lib/streams/stream_crud.ts index 245e06e8b4573..452b0f40cb38e 100644 --- a/x-pack/plugins/streams/server/lib/streams/stream_crud.ts +++ b/x-pack/plugins/streams/server/lib/streams/stream_crud.ts @@ -89,7 +89,6 @@ async function upsertInternalStream({ definition, scopedClusterClient }: BasePar type ListStreamsParams = BaseParams; export interface ListStreamResponse { - total: number; definitions: StreamDefinition[]; } @@ -103,12 +102,14 @@ export async function listStreams({ }); const dataStreams = await listDataStreamsAsStreams({ scopedClusterClient }); - const definitions = response.hits.hits.map((hit) => ({ ...hit._source!, managed: true })); - const total = response.hits.total!; + let definitions = response.hits.hits.map((hit) => ({ ...hit._source!, managed: true })); + const hasAccess = await Promise.all( + definitions.map((definition) => checkReadAccess({ id: definition.id, scopedClusterClient })) + ); + definitions = definitions.filter((_, index) => hasAccess[index]); return { definitions: [...definitions, ...dataStreams], - total: (typeof total === 'number' ? total : total.value) + dataStreams.length, }; } @@ -244,7 +245,9 @@ export async function readAncestors({ return { ancestors: await Promise.all( - ancestorIds.map((ancestorId) => readStream({ scopedClusterClient, id: ancestorId })) + ancestorIds.map((ancestorId) => + readStream({ scopedClusterClient, id: ancestorId, skipAccessCheck: true }) + ) ), }; } diff --git a/x-pack/plugins/streams/server/routes/index.ts b/x-pack/plugins/streams/server/routes/index.ts index 7267dbedeacff..cf130e99db3fc 100644 --- a/x-pack/plugins/streams/server/routes/index.ts +++ b/x-pack/plugins/streams/server/routes/index.ts @@ -14,6 +14,7 @@ import { forkStreamsRoute } from './streams/fork'; import { listStreamsRoute } from './streams/list'; import { readStreamRoute } from './streams/read'; import { resyncStreamsRoute } from './streams/resync'; +import { sampleStreamRoute } from './streams/sample'; import { streamsStatusRoutes } from './streams/settings'; export const streamsRouteRepository = { @@ -27,6 +28,7 @@ export const streamsRouteRepository = { ...streamsStatusRoutes, ...esqlRoutes, ...disableStreamsRoute, + ...sampleStreamRoute, }; export type StreamsRouteRepository = typeof streamsRouteRepository; diff --git a/x-pack/plugins/streams/server/routes/streams/edit.ts b/x-pack/plugins/streams/server/routes/streams/edit.ts index 6125aa2470b94..e280796bc9780 100644 --- a/x-pack/plugins/streams/server/routes/streams/edit.ts +++ b/x-pack/plugins/streams/server/routes/streams/edit.ts @@ -27,6 +27,7 @@ import { import { MalformedStreamId } from '../../lib/streams/errors/malformed_stream_id'; import { getParentId } from '../../lib/streams/helpers/hierarchy'; import { MalformedChildren } from '../../lib/streams/errors/malformed_children'; +import { validateCondition } from '../../lib/streams/helpers/condition_fields'; export const editStreamRoute = createServerRoute({ endpoint: 'PUT /api/streams/{id}', @@ -57,7 +58,7 @@ export const editStreamRoute = createServerRoute({ const parentId = getParentId(params.path.id); let parentDefinition: StreamDefinition | undefined; - const streamDefinition = { ...params.body }; + const streamDefinition = { ...params.body, id: params.path.id }; // always need to go from the leaves to the parent when syncing ingest pipelines, otherwise data // will be routed before the data stream is ready @@ -151,7 +152,7 @@ async function updateParentStream( async function validateStreamChildren( scopedClusterClient: IScopedClusterClient, id: string, - children: Array<{ id: string }> + children: StreamDefinition['children'] ) { try { const { definition: oldDefinition } = await readStream({ @@ -160,6 +161,9 @@ async function validateStreamChildren( }); const oldChildren = oldDefinition.children.map((child) => child.id); const newChildren = new Set(children.map((child) => child.id)); + children.forEach((child) => { + validateCondition(child.condition); + }); if (oldChildren.some((child) => !newChildren.has(child))) { throw new MalformedChildren( 'Cannot remove children from a stream, please delete the stream instead' diff --git a/x-pack/plugins/streams/server/routes/streams/fork.ts b/x-pack/plugins/streams/server/routes/streams/fork.ts index a4d846ceccb35..070dc66b9ab10 100644 --- a/x-pack/plugins/streams/server/routes/streams/fork.ts +++ b/x-pack/plugins/streams/server/routes/streams/fork.ts @@ -18,6 +18,7 @@ import { conditionSchema, streamDefinitonWithoutChildrenSchema } from '../../../ import { syncStream, readStream, validateAncestorFields } from '../../lib/streams/stream_crud'; import { MalformedStreamId } from '../../lib/streams/errors/malformed_stream_id'; import { isChildOf } from '../../lib/streams/helpers/hierarchy'; +import { validateCondition } from '../../lib/streams/helpers/condition_fields'; export const forkStreamsRoute = createServerRoute({ endpoint: 'POST /api/streams/{id}/_fork', @@ -48,6 +49,8 @@ export const forkStreamsRoute = createServerRoute({ throw new ForkConditionMissing('You must provide a condition to fork a stream'); } + validateCondition(params.body.condition); + const { scopedClusterClient } = await getScopedClients({ request }); const { definition: rootDefinition } = await readStream({ diff --git a/x-pack/plugins/streams/server/routes/streams/read.ts b/x-pack/plugins/streams/server/routes/streams/read.ts index 5c503e2b7e625..dbbda8c0dc5de 100644 --- a/x-pack/plugins/streams/server/routes/streams/read.ts +++ b/x-pack/plugins/streams/server/routes/streams/read.ts @@ -7,10 +7,10 @@ import { z } from '@kbn/zod'; import { notFound, internal } from '@hapi/boom'; +import { ReadStreamDefinition } from '../../../common/types'; import { createServerRoute } from '../create_server_route'; import { DefinitionNotFound } from '../../lib/streams/errors'; import { readAncestors, readStream } from '../../lib/streams/stream_crud'; -import { StreamDefinition } from '../../../common'; export const readStreamRoute = createServerRoute({ endpoint: 'GET /api/streams/{id}', @@ -33,11 +33,7 @@ export const readStreamRoute = createServerRoute({ request, logger, getScopedClients, - }): Promise< - StreamDefinition & { - inheritedFields: Array; - } - > => { + }): Promise => { try { const { scopedClusterClient } = await getScopedClients({ request }); const streamEntity = await readStream({ diff --git a/x-pack/plugins/streams/server/routes/streams/sample.ts b/x-pack/plugins/streams/server/routes/streams/sample.ts new file mode 100644 index 0000000000000..cd3a989c29109 --- /dev/null +++ b/x-pack/plugins/streams/server/routes/streams/sample.ts @@ -0,0 +1,105 @@ +/* + * 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 { conditionSchema } from '../../../common/types'; +import { createServerRoute } from '../create_server_route'; +import { DefinitionNotFound } from '../../lib/streams/errors'; +import { checkReadAccess } from '../../lib/streams/stream_crud'; +import { conditionToQueryDsl } from '../../lib/streams/helpers/condition_to_query_dsl'; +import { getFields, isComplete } from '../../lib/streams/helpers/condition_fields'; + +export const sampleStreamRoute = createServerRoute({ + endpoint: 'POST /api/streams/{id}/_sample', + 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({ + condition: z.optional(conditionSchema), + start: z.optional(z.number()), + end: z.optional(z.number()), + number: z.optional(z.number()), + }), + }), + handler: async ({ + response, + params, + request, + logger, + getScopedClients, + }): Promise<{ documents: unknown[] }> => { + 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 searchBody = { + query: { + bool: { + must: [ + isComplete(params.body.condition) + ? conditionToQueryDsl(params.body.condition) + : { match_all: {} }, + { + range: { + '@timestamp': { + gte: params.body.start, + lte: params.body.end, + format: 'epoch_millis', + }, + }, + }, + ], + }, + }, + // Conditions could be using fields which are not indexed or they could use it with other types than they are eventually mapped as. + // Because of this we can't rely on mapped fields to draw a sample, instead we need to use runtime fields to simulate what happens during + // ingest in the painless condition checks. + // This is less efficient than it could be - in some cases, these fields _are_ indexed with the right type and we could use them directly. + // This can be optimized in the future. + runtime_mappings: Object.fromEntries( + getFields(params.body.condition).map((field) => [ + field.name, + { type: field.type === 'string' ? 'keyword' : 'double' }, + ]) + ), + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + size: params.body.number, + }; + const results = await scopedClusterClient.asCurrentUser.search({ + index: params.path.id, + ...searchBody, + }); + + return { documents: results.hits.hits.map((hit) => hit._source) }; + } catch (e) { + if (e instanceof DefinitionNotFound) { + throw notFound(e); + } + + throw internal(e); + } + }, +}); diff --git a/x-pack/plugins/streams_app/.storybook/get_mock_streams_app_context.tsx b/x-pack/plugins/streams_app/.storybook/get_mock_streams_app_context.tsx index 1660042b2cb66..cb3148a7f6644 100644 --- a/x-pack/plugins/streams_app/.storybook/get_mock_streams_app_context.tsx +++ b/x-pack/plugins/streams_app/.storybook/get_mock_streams_app_context.tsx @@ -12,6 +12,7 @@ import type { StreamsPluginStart } from '@kbn/streams-plugin/public'; import type { ObservabilitySharedPluginStart } from '@kbn/observability-shared-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { SharePublicStart } from '@kbn/share-plugin/public/plugin'; +import { NavigationPublicStart } from '@kbn/navigation-plugin/public/types'; import type { StreamsAppKibanaContext } from '../public/hooks/use_kibana'; export function getMockStreamsAppContext(): StreamsAppKibanaContext { @@ -27,6 +28,7 @@ export function getMockStreamsAppContext(): StreamsAppKibanaContext { unifiedSearch: {} as unknown as UnifiedSearchPublicPluginStart, streams: {} as unknown as StreamsPluginStart, share: {} as unknown as SharePublicStart, + navigation: {} as unknown as NavigationPublicStart, }, }, services: { diff --git a/x-pack/plugins/streams_app/kibana.jsonc b/x-pack/plugins/streams_app/kibana.jsonc index c1480aba906de..09f356f0654ba 100644 --- a/x-pack/plugins/streams_app/kibana.jsonc +++ b/x-pack/plugins/streams_app/kibana.jsonc @@ -15,7 +15,8 @@ "data", "dataViews", "unifiedSearch", - "share" + "share", + "navigation" ], "requiredBundles": [ "kibanaReact" diff --git a/x-pack/plugins/streams_app/public/components/assets/illustration.png b/x-pack/plugins/streams_app/public/components/assets/illustration.png new file mode 100644 index 0000000000000..58b0f61a94399 Binary files /dev/null and b/x-pack/plugins/streams_app/public/components/assets/illustration.png differ diff --git a/x-pack/plugins/streams_app/public/components/condition_editor/index.tsx b/x-pack/plugins/streams_app/public/components/condition_editor/index.tsx new file mode 100644 index 0000000000000..e7e2a79b59294 --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/condition_editor/index.tsx @@ -0,0 +1,297 @@ +/* + * 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, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSelect, + EuiSwitch, + EuiText, +} from '@elastic/eui'; +import { + AndCondition, + BinaryFilterCondition, + Condition, + FilterCondition, + OrCondition, +} from '@kbn/streams-plugin/common/types'; +import React, { useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/css'; +import { CodeEditor } from '@kbn/code-editor'; + +export function ConditionEditor(props: { + condition: Condition; + readonly?: boolean; + onConditionChange?: (condition: Condition) => void; +}) { + if (!props.condition) { + return null; + } + if (props.readonly) { + return ( + + + + ); + } + return ( + {})} + /> + ); +} + +export function ConditionForm(props: { + condition: Condition; + onConditionChange: (condition: Condition) => void; +}) { + const [syntaxEditor, setSyntaxEditor] = React.useState(() => + Boolean(props.condition && !('operator' in props.condition)) + ); + const [jsonCondition, setJsonCondition] = React.useState(() => + JSON.stringify(props.condition, null, 2) + ); + useEffect(() => { + if (!syntaxEditor && props.condition) { + setJsonCondition(JSON.stringify(props.condition, null, 2)); + } + }, [syntaxEditor, props.condition]); + return ( + + + + + {i18n.translate('xpack.streams.conditionEditor.title', { defaultMessage: 'Condition' })} + + + setSyntaxEditor(!syntaxEditor)} + /> + + {syntaxEditor ? ( + { + setJsonCondition(e); + try { + const condition = JSON.parse(e); + props.onConditionChange(condition); + } catch (error: unknown) { + // do nothing + } + }} + /> + ) : ( + props.condition && + ('operator' in props.condition ? ( + + ) : ( +
{JSON.stringify(props.condition, null, 2)}
+ )) + )} +
+ ); +} + +const operatorMap = { + eq: i18n.translate('xpack.streams.filter.equals', { defaultMessage: 'equals' }), + neq: i18n.translate('xpack.streams.filter.notEquals', { defaultMessage: 'not equals' }), + lt: i18n.translate('xpack.streams.filter.lessThan', { defaultMessage: 'less than' }), + lte: i18n.translate('xpack.streams.filter.lessThanOrEquals', { + defaultMessage: 'less than or equals', + }), + gt: i18n.translate('xpack.streams.filter.greaterThan', { defaultMessage: 'greater than' }), + gte: i18n.translate('xpack.streams.filter.greaterThanOrEquals', { + defaultMessage: 'greater than or equals', + }), + contains: i18n.translate('xpack.streams.filter.contains', { defaultMessage: 'contains' }), + startsWith: i18n.translate('xpack.streams.filter.startsWith', { defaultMessage: 'starts with' }), + endsWith: i18n.translate('xpack.streams.filter.endsWith', { defaultMessage: 'ends with' }), + exists: i18n.translate('xpack.streams.filter.exists', { defaultMessage: 'exists' }), + notExists: i18n.translate('xpack.streams.filter.notExists', { defaultMessage: 'not exists' }), +}; + +function FilterForm(props: { + condition: FilterCondition; + onConditionChange: (condition: FilterCondition) => void; +}) { + return ( + + + { + props.onConditionChange({ ...props.condition, field: e.target.value }); + }} + /> + + + ({ + value, + text, + })) as Array<{ value: FilterCondition['operator']; text: string }> + } + value={props.condition.operator} + compressed + onChange={(e) => { + const newCondition: Partial = { + ...props.condition, + }; + + const newOperator = e.target.value as FilterCondition['operator']; + if ( + 'value' in newCondition && + (newOperator === 'exists' || newOperator === 'notExists') + ) { + delete newCondition.value; + } else if (!('value' in newCondition)) { + (newCondition as BinaryFilterCondition).value = ''; + } + props.onConditionChange({ + ...newCondition, + operator: newOperator, + } as FilterCondition); + }} + /> + + + {'value' in props.condition && ( + + { + props.onConditionChange({ + ...props.condition, + value: e.target.value, + } as BinaryFilterCondition); + }} + /> + + )} + + ); +} + +export function ConditionDisplay(props: { condition: Condition }) { + if (!props.condition) { + return null; + } + return ( + <> + {'or' in props.condition ? ( + + ) : 'and' in props.condition ? ( + + ) : ( + + )} + + ); +} + +function OrDisplay(props: { condition: OrCondition }) { + return ( +
+ {i18n.translate('xpack.streams.orDisplay.orLabel', { defaultMessage: 'Or' })} +
+ {props.condition.or.map((condition, index) => ( + + ))} +
+
+ ); +} + +function AndDisplay(props: { condition: AndCondition }) { + return ( +
+ {i18n.translate('xpack.streams.andDisplay.andLabel', { defaultMessage: 'And' })} +
+ {props.condition.and.map((condition, index) => ( + + ))} +
+
+ ); +} + +function FilterDisplay(props: { condition: FilterCondition }) { + return ( + + + {i18n.translate('xpack.streams.filter.field', { defaultMessage: 'Field' })} + + {props.condition.field} + + {i18n.translate('xpack.streams.filter.operator', { defaultMessage: 'Operator' })} + + {props.condition.operator} + {'value' in props.condition && ( + <> + + {i18n.translate('xpack.streams.filter.value', { defaultMessage: 'Value' })} + + {props.condition.value} + + )} + + ); +} diff --git a/x-pack/plugins/streams_app/public/components/entity_detail_view/index.tsx b/x-pack/plugins/streams_app/public/components/entity_detail_view/index.tsx index 8e423908af27d..4e1ec87866aee 100644 --- a/x-pack/plugins/streams_app/public/components/entity_detail_view/index.tsx +++ b/x-pack/plugins/streams_app/public/components/entity_detail_view/index.tsx @@ -7,6 +7,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiPanel, EuiBadge } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { css } from '@emotion/css'; import { StreamDefinition } from '@kbn/streams-plugin/common'; import { useStreamsAppBreadcrumbs } from '../../hooks/use_streams_app_breadcrumbs'; import { useStreamsAppRouter } from '../../hooks/use_streams_app_router'; @@ -73,7 +74,13 @@ export function EntityDetailViewWithoutParams({ const selectedTabObject = tabMap[selectedTab]; return ( - + diff --git a/x-pack/plugins/streams_app/public/components/nested_view/index.tsx b/x-pack/plugins/streams_app/public/components/nested_view/index.tsx new file mode 100644 index 0000000000000..e19a97053f080 --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/nested_view/index.tsx @@ -0,0 +1,40 @@ +/* + * 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 { css } from '@emotion/css'; +import { euiThemeVars } from '@kbn/ui-theme'; + +const borderSpec = `1px solid ${euiThemeVars.euiColorLightShade}`; + +export function NestedView({ children, last }: { children: React.ReactNode; last?: boolean }) { + return ( +
+
+ {children} +
+ ); +} diff --git a/x-pack/plugins/streams_app/public/components/preview_table/index.tsx b/x-pack/plugins/streams_app/public/components/preview_table/index.tsx new file mode 100644 index 0000000000000..22db6ff294079 --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/preview_table/index.tsx @@ -0,0 +1,71 @@ +/* + * 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 { EuiDataGrid } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useMemo, useState } from 'react'; + +export function PreviewTable({ documents }: { documents: unknown[] }) { + const [height, setHeight] = 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(() => { + setHeight(`100%`); + }, 50); + }, []); + + const columns = useMemo(() => { + const cols = new Set(); + documents.forEach((doc) => { + if (!doc || typeof doc !== 'object') { + return; + } + Object.keys(doc).forEach((key) => { + cols.add(key); + }); + }); + return Array.from(cols); + }, [documents]); + + const gridColumns = useMemo(() => { + return Array.from(columns).map((column) => ({ + id: column, + displayAsText: column, + })); + }, [columns]); + + return ( + {}, + canDragAndDropColumns: false, + }} + toolbarVisibility={false} + rowCount={documents.length} + height={height} + renderCellValue={({ rowIndex, columnId }) => { + const doc = documents[rowIndex]; + if (!doc || typeof doc !== 'object') { + return ''; + } + const value = (doc as Record)[columnId]; + if (value === undefined || value === null) { + return ''; + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); + }} + /> + ); +} diff --git a/x-pack/plugins/streams_app/public/components/stream_delete_modal/index.tsx b/x-pack/plugins/streams_app/public/components/stream_delete_modal/index.tsx new file mode 100644 index 0000000000000..5b6c04fa805a9 --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/stream_delete_modal/index.tsx @@ -0,0 +1,121 @@ +/* + * 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, + EuiButtonEmpty, + EuiFlexGroup, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiText, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useAbortController } from '@kbn/observability-utils-browser/hooks/use_abort_controller'; +import React from 'react'; +import { useKibana } from '../../hooks/use_kibana'; + +export function StreamDeleteModal({ + closeModal, + clearChildUnderEdit, + refreshDefinition, + id, +}: { + closeModal: () => void; + clearChildUnderEdit: () => void; + refreshDefinition: () => void; + id: string; +}) { + const { + core: { notifications }, + dependencies: { + start: { + streams: { streamsRepositoryClient }, + }, + }, + } = useKibana(); + const abortController = useAbortController(); + const [deleteInProgress, setDeleteInProgress] = React.useState(false); + const modalTitleId = useGeneratedHtmlId(); + return ( + + + + {i18n.translate('xpack.streams.streamDetailRouting.deleteModalTitle', { + defaultMessage: 'Are you sure you want to delete this data stream?', + })} + + + + + + {i18n.translate('xpack.streams.streamDetailRouting.deleteModalDescription', { + defaultMessage: + 'Deleting this stream will remove all of its children and the data will no longer be routed. All existing data will be removed as well.', + })} + + + + + + + {i18n.translate('xpack.streams.streamDetailRouting.deleteModalCancel', { + defaultMessage: 'Cancel', + })} + + { + try { + setDeleteInProgress(true); + await streamsRepositoryClient.fetch('DELETE /api/streams/{id}', { + signal: abortController.signal, + params: { + path: { + id, + }, + }, + }); + setDeleteInProgress(false); + notifications.toasts.addSuccess({ + title: i18n.translate('xpack.streams.streamDetailRouting.deleted', { + defaultMessage: 'Stream deleted', + }), + }); + clearChildUnderEdit(); + closeModal(); + refreshDefinition(); + } catch (error) { + setDeleteInProgress(false); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.streams.failedToDelete', { + defaultMessage: 'Failed to delete stream {id}', + values: { + id, + }, + }), + }); + } + }} + isLoading={deleteInProgress} + > + {i18n.translate('xpack.streams.streamDetailRouting.delete', { + defaultMessage: 'Delete', + })} + + + + + ); +} diff --git a/x-pack/plugins/streams_app/public/components/stream_detail_management/index.tsx b/x-pack/plugins/streams_app/public/components/stream_detail_management/index.tsx index 749b0e659d659..1e66490bca3c9 100644 --- a/x-pack/plugins/streams_app/public/components/stream_detail_management/index.tsx +++ b/x-pack/plugins/streams_app/public/components/stream_detail_management/index.tsx @@ -6,7 +6,8 @@ */ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { StreamDefinition } from '@kbn/streams-plugin/common'; +import { ReadStreamDefinition, StreamDefinition } from '@kbn/streams-plugin/common'; +import { css } from '@emotion/css'; import { EuiButtonGroup, EuiFlexGroup, EuiFlexItem, EuiListGroup, EuiText } from '@elastic/eui'; import { useStreamsAppParams } from '../../hooks/use_streams_app_params'; import { RedirectTo } from '../redirect_to'; @@ -26,7 +27,7 @@ export function StreamDetailManagement({ definition, refreshDefinition, }: { - definition?: StreamDefinition; + definition?: ReadStreamDefinition; refreshDefinition: () => void; }) { const { @@ -91,7 +92,13 @@ export function StreamDetailManagement({ const selectedTabObject = tabs[subtab]; return ( - + - {selectedTabObject.content} + + {selectedTabObject.content} + ); } diff --git a/x-pack/plugins/streams_app/public/components/stream_detail_routing/index.tsx b/x-pack/plugins/streams_app/public/components/stream_detail_routing/index.tsx index af65c7eab4235..ca58051f9db2b 100644 --- a/x-pack/plugins/streams_app/public/components/stream_detail_routing/index.tsx +++ b/x-pack/plugins/streams_app/public/components/stream_detail_routing/index.tsx @@ -4,15 +4,778 @@ * 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 { + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiImage, + EuiLoadingSpinner, + EuiPanel, + EuiResizableContainer, + EuiSpacer, + EuiText, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/css'; +import { i18n } from '@kbn/i18n'; +import { useAbortController } from '@kbn/observability-utils-browser/hooks/use_abort_controller'; +import { useDateRange } from '@kbn/observability-utils-browser/hooks/use_date_range'; +import { ReadStreamDefinition } from '@kbn/streams-plugin/common'; import React from 'react'; +import { StreamChild } from '@kbn/streams-plugin/common/types'; +import { AbortableAsyncState } from '@kbn/observability-utils-browser/hooks/use_abortable_async'; +import { useKibana } from '../../hooks/use_kibana'; +import { useStreamsAppFetch } from '../../hooks/use_streams_app_fetch'; +import { StreamsAppSearchBar } from '../streams_app_search_bar'; +import { ConditionEditor } from '../condition_editor'; +import { useDebounced } from '../../util/use_debounce'; +import { useStreamsAppRouter } from '../../hooks/use_streams_app_router'; +import { NestedView } from '../nested_view'; +import illustration from '../assets/illustration.png'; +import { PreviewTable } from '../preview_table'; +import { StreamDeleteModal } from '../stream_delete_modal'; + +function useRoutingState() { + const [childUnderEdit, setChildUnderEdit] = React.useState< + { isNew: boolean; child: StreamChild } | undefined + >(); + + const debouncedChildUnderEdit = useDebounced(childUnderEdit, 300); + + const [saveInProgress, setSaveInProgress] = React.useState(false); + const [showDeleteModal, setShowDeleteModal] = React.useState(false); + + return { + debouncedChildUnderEdit, + childUnderEdit, + setChildUnderEdit, + saveInProgress, + setSaveInProgress, + showDeleteModal, + setShowDeleteModal, + }; +} export function StreamDetailRouting({ - definition: _definition, - refreshDefinition: _refreshDefinition, + definition, + refreshDefinition, +}: { + definition?: ReadStreamDefinition; + refreshDefinition: () => void; +}) { + const theme = useEuiTheme().euiTheme; + const routingAppState = useRoutingState(); + + if (!definition) { + return null; + } + + const closeModal = () => routingAppState.setShowDeleteModal(false); + + return ( + <> + {routingAppState.showDeleteModal && routingAppState.childUnderEdit && ( + routingAppState.setChildUnderEdit(undefined)} + refreshDefinition={refreshDefinition} + id={routingAppState.childUnderEdit.child.id} + /> + )} + + + + {(EuiResizablePanel, EuiResizableButton) => ( + <> + + + + + + + + + + + )} + + + + + + + + ); +} + +function ControlBar({ + definition, + routingAppState, + refreshDefinition, }: { - definition?: StreamDefinition; + definition: ReadStreamDefinition; + routingAppState: ReturnType; refreshDefinition: () => void; }) { - return <>{'TODO'}; + const { + core: { notifications }, + dependencies: { + start: { + streams: { streamsRepositoryClient }, + }, + }, + } = useKibana(); + + const { signal } = useAbortController(); + + if (!routingAppState.childUnderEdit) { + return ( + + + {i18n.translate('xpack.streams.streamDetailRouting.save', { + defaultMessage: 'Save', + })} + + + ); + } + + function forkChild() { + if (!routingAppState.childUnderEdit) { + return; + } + return streamsRepositoryClient.fetch('POST /api/streams/{id}/_fork', { + signal, + params: { + path: { + id: definition.id, + }, + body: { + condition: routingAppState.childUnderEdit.child.condition, + stream: { + id: routingAppState.childUnderEdit.child.id, + processing: [], + fields: [], + }, + }, + }, + }); + } + + function updateChild() { + if (!routingAppState.childUnderEdit) { + return; + } + + const childUnderEdit = routingAppState.childUnderEdit.child; + const { inheritedFields, id, ...definitionToUpdate } = definition; + return streamsRepositoryClient.fetch('PUT /api/streams/{id}', { + signal, + params: { + path: { + id: definition.id, + }, + body: { + ...definitionToUpdate, + children: definition.children.map((child) => + child.id === childUnderEdit.id ? childUnderEdit : child + ), + }, + }, + }); + } + + async function saveOrUpdateChild() { + if (!routingAppState.childUnderEdit) { + return; + } + try { + routingAppState.setSaveInProgress(true); + + if (routingAppState.childUnderEdit.isNew) { + await forkChild(); + } else { + await updateChild(); + } + + routingAppState.setSaveInProgress(false); + notifications.toasts.addSuccess({ + title: i18n.translate('xpack.streams.streamDetailRouting.saved', { + defaultMessage: 'Stream saved', + }), + }); + routingAppState.setChildUnderEdit(undefined); + refreshDefinition(); + } catch (error) { + routingAppState.setSaveInProgress(false); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.streams.failedToSave', { + defaultMessage: 'Failed to save', + }), + toastMessage: 'body' in error ? error.body.message : error.message, + }); + } + } + + return ( + + {!routingAppState.childUnderEdit.isNew && ( + <> + { + routingAppState.setShowDeleteModal(true); + }} + > + {i18n.translate('xpack.streams.streamDetailRouting.remove', { + defaultMessage: 'Remove', + })} + + + + )} + { + routingAppState.setChildUnderEdit(undefined); + }} + > + {i18n.translate('xpack.streams.streamDetailRouting.cancel', { + defaultMessage: 'Cancel', + })} + + + {routingAppState.childUnderEdit.isNew + ? i18n.translate('xpack.streams.streamDetailRouting.add', { + defaultMessage: 'Save', + }) + : i18n.translate('xpack.streams.streamDetailRouting.change', { + defaultMessage: 'Change routing', + })} + + + ); +} + +function PreviewPanel({ + definition, + routingAppState, +}: { + definition: ReadStreamDefinition; + routingAppState: ReturnType; +}) { + const { + dependencies: { + start: { + data, + streams: { streamsRepositoryClient }, + }, + }, + } = useKibana(); + + const { + timeRange, + absoluteTimeRange: { start, end }, + setTimeRange, + } = useDateRange({ data }); + + const previewSampleFetch = useStreamsAppFetch( + ({ signal }) => { + if ( + !definition || + !routingAppState.debouncedChildUnderEdit || + !routingAppState.debouncedChildUnderEdit.isNew + ) { + return Promise.resolve({ documents: [] }); + } + return streamsRepositoryClient.fetch('POST /api/streams/{id}/_sample', { + signal, + params: { + path: { + id: definition.id, + }, + body: { + condition: routingAppState.debouncedChildUnderEdit.child.condition, + start: start?.valueOf(), + end: end?.valueOf(), + number: 100, + }, + }, + }); + }, + [definition, routingAppState.debouncedChildUnderEdit, streamsRepositoryClient, start, end], + { + disableToastOnError: true, + } + ); + + let content = ( + + ); + + if (routingAppState.debouncedChildUnderEdit?.isNew) { + if (previewSampleFetch.error) { + content = ( + + + + {i18n.translate('xpack.streams.streamDetail.preview.error', { + defaultMessage: 'Error loading preview', + })} + + + + ); + } else if (previewSampleFetch.value?.documents && previewSampleFetch.value.documents.length) { + content = ( + + + + ); + } + } + + return ( + <> + + + + + + + {i18n.translate('xpack.streams.streamDetail.preview.header', { + defaultMessage: 'Data Preview', + })} + {previewSampleFetch.loading && } + + + + + { + if (!isUpdate) { + previewSampleFetch.refresh(); + return; + } + + if (dateRange) { + setTimeRange({ + from: dateRange.from, + to: dateRange?.to, + mode: dateRange.mode, + }); + } + }} + onRefresh={() => { + previewSampleFetch.refresh(); + }} + dateRangeFrom={timeRange.from} + dateRangeTo={timeRange.to} + /> + + + + + {content} + + ); +} + +function PreviewPanelIllustration({ + previewSampleFetch, + routingAppState, +}: { + routingAppState: ReturnType; + previewSampleFetch: AbortableAsyncState<{ + documents: unknown[]; + }>; +}) { + return ( + + + + {previewSampleFetch.loading ? ( + + + + ) : ( + <> + {routingAppState.debouncedChildUnderEdit && + routingAppState.debouncedChildUnderEdit.isNew && ( + + {i18n.translate('xpack.streams.streamDetail.preview.empty', { + defaultMessage: 'No documents to preview', + })} + + )} + {routingAppState.debouncedChildUnderEdit && + !routingAppState.debouncedChildUnderEdit.isNew && ( + + {i18n.translate('xpack.streams.streamDetail.preview.editPreviewMessage', { + defaultMessage: 'Preview is not available while editing streams', + })} + + )} + {!routingAppState.debouncedChildUnderEdit && ( + <> + + {i18n.translate('xpack.streams.streamDetail.preview.editPreviewMessageEmpty', { + defaultMessage: 'Your preview will appear here', + })} + + + {i18n.translate( + 'xpack.streams.streamDetail.preview.editPreviewMessageEmptyDescription', + { + defaultMessage: + 'Create a new child stream to see what will be routed to it based on the conditions', + } + )} + + + )} + + )} + + + ); +} + +function ChildStreamList({ + definition, + routingAppState: { childUnderEdit, setChildUnderEdit }, +}: { + definition: ReadStreamDefinition; + routingAppState: ReturnType; +}) { + return ( + + + + {i18n.translate('xpack.streams.streamDetailRouting.rules.header', { + defaultMessage: 'Routing rules', + })} + + + + + + {definition.children.map((child, i) => ( + + { + if (child.id === childUnderEdit?.child.id) { + setChildUnderEdit(undefined); + } else { + setChildUnderEdit({ isNew: false, child }); + } + }} + onChildChange={(newChild) => { + setChildUnderEdit({ + isNew: false, + child: newChild, + }); + }} + /> + + ))} + {childUnderEdit?.isNew ? ( + + { + if (!newChild) { + setChildUnderEdit(undefined); + return; + } + setChildUnderEdit({ + isNew: true, + child: newChild, + }); + }} + /> + + ) : ( + + + { + setChildUnderEdit({ + isNew: true, + child: { + id: `${definition.id}.child`, + condition: { + field: '', + operator: 'eq', + value: '', + }, + }, + }); + }} + > + {i18n.translate('xpack.streams.streamDetailRouting.addRule', { + defaultMessage: 'Create a new child stream', + })} + + + + )} + + + ); +} + +function CurrentStreamEntry({ definition }: { definition: ReadStreamDefinition }) { + return ( + + + {definition.id} + + {i18n.translate('xpack.streams.streamDetailRouting.currentStream', { + defaultMessage: 'Current stream', + })} + + + + ); +} + +function PreviousStreamEntry({ definition }: { definition: ReadStreamDefinition }) { + const router = useStreamsAppRouter(); + + const parentId = definition.id.split('.').slice(0, -1).join('.'); + if (parentId === '') { + return null; + } + + return ( + + + + + {i18n.translate('xpack.streams.streamDetailRouting.previousStream', { + defaultMessage: '.. (Previous stream)', + })} + + + + + ); +} + +function RoutingStreamEntry({ + child, + onChildChange, + onEditStateChange, + edit, +}: { + child: StreamChild; + onChildChange: (child: StreamChild) => void; + onEditStateChange: () => void; + edit?: boolean; +}) { + const router = useStreamsAppRouter(); + return ( + + + + {child.id} + + { + onEditStateChange(); + }} + aria-label={i18n.translate('xpack.streams.streamDetailRouting.edit', { + defaultMessage: 'Edit', + })} + /> + + + {child.condition && ( + { + onChildChange({ + ...child, + condition, + }); + }} + /> + )} + {!child.condition && ( + + {i18n.translate('xpack.streams.streamDetailRouting.noCondition', { + defaultMessage: 'No condition, no documents will be routed', + })} + + )} + + ); +} + +function NewRoutingStreamEntry({ + child, + onChildChange, +}: { + child: StreamChild; + onChildChange: (child?: StreamChild) => void; +}) { + return ( + + + + { + onChildChange({ + ...child, + id: e.target.value, + }); + }} + /> + + {child.condition && ( + { + onChildChange({ + ...child, + condition, + }); + }} + /> + )} + {!child.condition && ( + + {i18n.translate('xpack.streams.streamDetailRouting.noCondition', { + defaultMessage: 'No condition, no documents will be routed', + })} + + )} + + + ); } diff --git a/x-pack/plugins/streams_app/public/components/stream_detail_view/index.tsx b/x-pack/plugins/streams_app/public/components/stream_detail_view/index.tsx index d091fb5758a1e..b0a2307f7b2b7 100644 --- a/x-pack/plugins/streams_app/public/components/stream_detail_view/index.tsx +++ b/x-pack/plugins/streams_app/public/components/stream_detail_view/index.tsx @@ -14,10 +14,12 @@ import { StreamDetailOverview } from '../stream_detail_overview'; import { StreamDetailManagement } from '../stream_detail_management'; export function StreamDetailView() { - const { path } = useStreamsAppParams('/{key}/*'); + const params1 = useStreamsAppParams('/{key}/{tab}', true); - const key = path.key; - const tab = 'tab' in path ? path.tab : 'management'; + const params2 = useStreamsAppParams('/{key}/management/{subtab}', true); + + const key = params1?.path?.key || params2.path.key; + const tab = params1?.path?.tab || 'management'; const { dependencies: { diff --git a/x-pack/plugins/streams_app/public/components/streams_app_page_body/index.tsx b/x-pack/plugins/streams_app/public/components/streams_app_page_body/index.tsx index 04b44f82df0fd..1718e2e638a51 100644 --- a/x-pack/plugins/streams_app/public/components/streams_app_page_body/index.tsx +++ b/x-pack/plugins/streams_app/public/components/streams_app_page_body/index.tsx @@ -18,6 +18,7 @@ export function StreamsAppPageBody({ children }: { children: React.ReactNode }) border-top: 1px solid ${theme.colors.lightShade}; border-radius: 0px; display: flex; + overflow-y: auto; `} paddingSize="l" > diff --git a/x-pack/plugins/streams_app/public/components/streams_app_page_template/index.tsx b/x-pack/plugins/streams_app/public/components/streams_app_page_template/index.tsx index 942d2937dba81..280c32841811a 100644 --- a/x-pack/plugins/streams_app/public/components/streams_app_page_template/index.tsx +++ b/x-pack/plugins/streams_app/public/components/streams_app_page_template/index.tsx @@ -6,23 +6,32 @@ */ import { css } from '@emotion/css'; import React from 'react'; -import { EuiPanel, EuiSpacer } from '@elastic/eui'; +import { EuiPanel } from '@elastic/eui'; +import useObservable from 'react-use/lib/useObservable'; import { useKibana } from '../../hooks/use_kibana'; export function StreamsAppPageTemplate({ children }: { children: React.ReactNode }) { const { dependencies: { - start: { observabilityShared }, + start: { observabilityShared, navigation }, }, } = useKibana(); const { PageTemplate } = observabilityShared.navigation; + const isSolutionNavEnabled = useObservable(navigation.isSolutionNavEnabled$); + return ( - {children} diff --git a/x-pack/plugins/streams_app/public/components/streams_app_search_bar/index.tsx b/x-pack/plugins/streams_app/public/components/streams_app_search_bar/index.tsx index 563fb752efbd5..cd07f540f3ba1 100644 --- a/x-pack/plugins/streams_app/public/components/streams_app_search_bar/index.tsx +++ b/x-pack/plugins/streams_app/public/components/streams_app_search_bar/index.tsx @@ -4,17 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { css } from '@emotion/css'; import type { TimeRange } from '@kbn/es-query'; import { SearchBar } from '@kbn/unified-search-plugin/public'; import React, { useMemo } from 'react'; import type { DataView } from '@kbn/data-views-plugin/common'; import { useKibana } from '../../hooks/use_kibana'; -const parentClassName = css` - width: 100%; -`; - interface Props { query?: string; dateRangeFrom?: string; @@ -47,32 +42,30 @@ export function StreamsAppSearchBar({ const showQueryInput = query === undefined; return ( -
- { - onQuerySubmit?.( - { dateRange, query: (nextQuery?.query as string | undefined) ?? '' }, - isUpdate - ); - }} - onQueryChange={({ dateRange, query: nextQuery }) => { - onQueryChange?.({ dateRange, query: (nextQuery?.query as string | undefined) ?? '' }); - }} - query={queryObj} - showQueryInput={showQueryInput} - showFilterBar={false} - showQueryMenu={false} - showDatePicker={Boolean(dateRangeFrom && dateRangeTo)} - showSubmitButton={true} - dateRangeFrom={dateRangeFrom} - dateRangeTo={dateRangeTo} - onRefresh={onRefresh} - displayStyle="inPage" - disableQueryLanguageSwitcher - placeholder={placeholder} - indexPatterns={dataViews} - /> -
+ { + onQuerySubmit?.( + { dateRange, query: (nextQuery?.query as string | undefined) ?? '' }, + isUpdate + ); + }} + onQueryChange={({ dateRange, query: nextQuery }) => { + onQueryChange?.({ dateRange, query: (nextQuery?.query as string | undefined) ?? '' }); + }} + query={queryObj} + showQueryInput={showQueryInput} + showFilterBar={false} + showQueryMenu={false} + showDatePicker={Boolean(dateRangeFrom && dateRangeTo)} + showSubmitButton={true} + dateRangeFrom={dateRangeFrom} + dateRangeTo={dateRangeTo} + onRefresh={onRefresh} + displayStyle="inPage" + disableQueryLanguageSwitcher + placeholder={placeholder} + indexPatterns={dataViews} + /> ); } diff --git a/x-pack/plugins/streams_app/public/routes/config.tsx b/x-pack/plugins/streams_app/public/routes/config.tsx index 444218c6f9769..5887528f07b16 100644 --- a/x-pack/plugins/streams_app/public/routes/config.tsx +++ b/x-pack/plugins/streams_app/public/routes/config.tsx @@ -68,6 +68,15 @@ const streamsAppRoutes = { }), }), }, + '/{key}/{tab}/{subtab}': { + element: , + params: t.type({ + path: t.type({ + tab: t.string, + subtab: t.string, + }), + }), + }, }, }, '/': { diff --git a/x-pack/plugins/streams_app/public/types.ts b/x-pack/plugins/streams_app/public/types.ts index 58d44784fe031..680dd008d2e1f 100644 --- a/x-pack/plugins/streams_app/public/types.ts +++ b/x-pack/plugins/streams_app/public/types.ts @@ -16,6 +16,7 @@ import type { import type { StreamsPluginSetup, StreamsPluginStart } from '@kbn/streams-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { SharePublicSetup, SharePublicStart } from '@kbn/share-plugin/public/plugin'; +import { NavigationPublicStart } from '@kbn/navigation-plugin/public/types'; /* eslint-disable @typescript-eslint/no-empty-interface*/ export interface ConfigSchema {} @@ -36,6 +37,7 @@ export interface StreamsAppStartDependencies { observabilityShared: ObservabilitySharedPluginStart; unifiedSearch: UnifiedSearchPublicPluginStart; share: SharePublicStart; + navigation: NavigationPublicStart; } export interface StreamsAppPublicSetup {} diff --git a/x-pack/plugins/streams_app/public/util/use_debounce.ts b/x-pack/plugins/streams_app/public/util/use_debounce.ts new file mode 100644 index 0000000000000..eee9accd2e29e --- /dev/null +++ b/x-pack/plugins/streams_app/public/util/use_debounce.ts @@ -0,0 +1,23 @@ +/* + * 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 useDebounce from 'react-use/lib/useDebounce'; +import { useState } from 'react'; + +export function useDebounced(value: T, debounceDelay: number = 300) { + const [debouncedValue, setValue] = useState(value); + + useDebounce( + () => { + setValue(value); + }, + debounceDelay, + [value, setValue] + ); + + return debouncedValue; +} diff --git a/x-pack/plugins/streams_app/tsconfig.json b/x-pack/plugins/streams_app/tsconfig.json index 39acb94665ae5..cba6a2d993bd4 100644 --- a/x-pack/plugins/streams_app/tsconfig.json +++ b/x-pack/plugins/streams_app/tsconfig.json @@ -33,5 +33,8 @@ "@kbn/streams-plugin", "@kbn/share-plugin", "@kbn/observability-utils-server", + "@kbn/code-editor", + "@kbn/ui-theme", + "@kbn/navigation-plugin", ] }