diff --git a/x-pack/platform/plugins/shared/streams/server/lib/sig_events/tasks/insights_discovery.ts b/x-pack/platform/plugins/shared/streams/server/lib/sig_events/tasks/insights_discovery.ts index 7b5f8b03b3dd6..410f0edc6f0a1 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/sig_events/tasks/insights_discovery.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/sig_events/tasks/insights_discovery.ts @@ -31,6 +31,7 @@ export interface InsightsDiscoveryTaskResult { export interface InsightsDiscoveryTaskParams { /** When provided, only generate insights for these stream names. Otherwise all streams are used. */ streamNames?: string[]; + connectorId?: string; } export const STREAMS_INSIGHTS_DISCOVERY_TASK_TYPE = 'streams_insights_discovery'; @@ -47,8 +48,11 @@ export function createStreamsInsightsDiscoveryTask(taskContext: TaskContext) { } const { fakeRequest } = runContext; - const { streamNames, _task } = runContext.taskInstance - .params as TaskParams; + const { + streamNames, + connectorId: connectorIdOverride, + _task, + } = runContext.taskInstance.params as TaskParams; const { taskClient, @@ -62,12 +66,14 @@ export function createStreamsInsightsDiscoveryTask(taskContext: TaskContext) { }); const taskLogger = taskContext.logger.get('insights_discovery'); - const connectorId = await resolveConnectorForFeature({ - searchInferenceEndpoints: taskContext.server.searchInferenceEndpoints, - featureId: STREAMS_SIG_EVENTS_DISCOVERY_INFERENCE_FEATURE_ID, - featureName: 'discovery', - request: fakeRequest, - }); + const connectorId = + connectorIdOverride ?? + (await resolveConnectorForFeature({ + searchInferenceEndpoints: taskContext.server.searchInferenceEndpoints, + featureId: STREAMS_SIG_EVENTS_DISCOVERY_INFERENCE_FEATURE_ID, + featureName: 'discovery', + request: fakeRequest, + })); taskLogger.debug(`Using connector ${connectorId} for discovery`); const boundInferenceClient = inferenceClient.bindTo({ connectorId }); @@ -116,7 +122,7 @@ export function createStreamsInsightsDiscoveryTask(taskContext: TaskContext) { await taskClient.complete( _task, - { streamNames }, + { streamNames, connectorId: connectorIdOverride }, { insights, tokensUsed: result.tokens_used } ); } catch (error) { @@ -144,7 +150,7 @@ export function createStreamsInsightsDiscoveryTask(taskContext: TaskContext) { await taskClient.fail( _task, - { streamNames }, + { streamNames, connectorId: connectorIdOverride }, errorMessage ); return getDeleteTaskRunResult(); diff --git a/x-pack/platform/plugins/shared/streams/server/lib/sig_events/tasks/significant_events_queries_generation.ts b/x-pack/platform/plugins/shared/streams/server/lib/sig_events/tasks/significant_events_queries_generation.ts index 410b06dd9001e..3d766e36effca 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/sig_events/tasks/significant_events_queries_generation.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/sig_events/tasks/significant_events_queries_generation.ts @@ -28,6 +28,7 @@ export interface SignificantEventsQueriesGenerationTaskParams { end: number; sampleDocsSize?: number; streamName: string; + connectorId?: string; } export const SIGNIFICANT_EVENTS_QUERIES_GENERATION_TASK_TYPE = @@ -49,7 +50,14 @@ export function createStreamsSignificantEventsQueriesGenerationTask(taskContext: } const { fakeRequest } = runContext; - const { start, end, sampleDocsSize, streamName, _task } = runContext.taskInstance + const { + start, + end, + sampleDocsSize, + streamName, + connectorId: connectorIdOverride, + _task, + } = runContext.taskInstance .params as TaskParams; const { @@ -64,12 +72,14 @@ export function createStreamsSignificantEventsQueriesGenerationTask(taskContext: }); const taskLogger = taskContext.logger.get('significant_events_queries_generation'); - const connectorId = await resolveConnectorForFeature({ - searchInferenceEndpoints: taskContext.server.searchInferenceEndpoints, - featureId: STREAMS_SIG_EVENTS_KI_QUERY_GENERATION_INFERENCE_FEATURE_ID, - featureName: 'query generation', - request: fakeRequest, - }); + const connectorId = + connectorIdOverride ?? + (await resolveConnectorForFeature({ + searchInferenceEndpoints: taskContext.server.searchInferenceEndpoints, + featureId: STREAMS_SIG_EVENTS_KI_QUERY_GENERATION_INFERENCE_FEATURE_ID, + featureName: 'query generation', + request: fakeRequest, + })); taskLogger.debug(`Using connector ${connectorId} for rule generation`); try { @@ -113,7 +123,11 @@ export function createStreamsSignificantEventsQueriesGenerationTask(taskContext: await taskClient.complete< SignificantEventsQueriesGenerationTaskParams, SignificantEventsQueriesGenerationResult - >(_task, { start, end, sampleDocsSize, streamName }, result); + >( + _task, + { start, end, sampleDocsSize, streamName, connectorId: connectorIdOverride }, + result + ); } catch (error) { if (isDefinitionNotFoundError(error)) { taskContext.logger.debug( @@ -146,7 +160,13 @@ export function createStreamsSignificantEventsQueriesGenerationTask(taskContext: await taskClient.fail( _task, - { start, end, sampleDocsSize, streamName }, + { + start, + end, + sampleDocsSize, + streamName, + connectorId: connectorIdOverride, + }, errorMessage ); diff --git a/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/features_identification/index.ts b/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/features_identification/index.ts index fde8c2698cf37..8e3a4eba38208 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/features_identification/index.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/features_identification/index.ts @@ -335,6 +335,7 @@ export interface FeaturesIdentificationTaskParams { start: number; end: number; streamName: string; + connectorId?: string; } export const FEATURES_IDENTIFICATION_TASK_TYPE = 'streams_features_identification'; @@ -355,8 +356,13 @@ export function createStreamsFeaturesIdentificationTask(taskContext: TaskContext } const { fakeRequest } = runContext; - const { start, end, streamName, _task } = runContext.taskInstance - .params as TaskParams; + const { + start, + end, + streamName, + connectorId: connectorIdOverride, + _task, + } = runContext.taskInstance.params as TaskParams; const runId = uuid(); const trackEmptyTelemetry = (state: 'canceled' | 'failure') => { @@ -395,12 +401,14 @@ export function createStreamsFeaturesIdentificationTask(taskContext: TaskContext }); const taskLogger = taskContext.logger.get('features_identification', streamName); - const connectorId = await resolveConnectorForFeature({ - searchInferenceEndpoints: taskContext.server.searchInferenceEndpoints, - featureId: STREAMS_SIG_EVENTS_KI_EXTRACTION_INFERENCE_FEATURE_ID, - featureName: 'knowledge indicator extraction', - request: fakeRequest, - }); + const connectorId = + connectorIdOverride ?? + (await resolveConnectorForFeature({ + searchInferenceEndpoints: taskContext.server.searchInferenceEndpoints, + featureId: STREAMS_SIG_EVENTS_KI_EXTRACTION_INFERENCE_FEATURE_ID, + featureName: 'knowledge indicator extraction', + request: fakeRequest, + })); taskLogger.debug(`Using connector ${connectorId} for knowledge indicator extraction`); let hasTrackedIteration = false; @@ -509,7 +517,7 @@ export function createStreamsFeaturesIdentificationTask(taskContext: TaskContext await taskClient.complete( _task, - { start, end, streamName }, + { start, end, streamName, connectorId: connectorIdOverride }, { features: allFeatures, durationMs, @@ -566,7 +574,7 @@ export function createStreamsFeaturesIdentificationTask(taskContext: TaskContext await taskClient.fail( _task, - { start, end, streamName }, + { start, end, streamName, connectorId: connectorIdOverride }, errorMessage, { features: [], diff --git a/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/onboarding.ts b/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/onboarding.ts index b8ad384090de0..659f5a5d2971b 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/onboarding.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/onboarding.ts @@ -42,6 +42,10 @@ export interface OnboardingTaskParams { to: number; steps: OnboardingStep[]; saveQueries: boolean; + connectors?: { + features?: string; + queries?: string; + }; } export const STREAMS_ONBOARDING_TASK_TYPE = 'streams_onboarding'; @@ -51,6 +55,31 @@ export function getOnboardingTaskId(streamName: string, saveQueries: boolean = t return saveQueries ? base : `${base}_no_save_queries`; } +const FEATURES_IDENTIFICATION_RECENCY_MS = 12 * 60 * 60 * 1000; // 12 hours + +async function areFeaturesUpToDate({ + taskClient, + featuresTaskId, +}: { + taskClient: TaskClient; + featuresTaskId: string; +}) { + const featuresTask = await taskClient.get< + FeaturesIdentificationTaskParams, + IdentifyFeaturesResult + >(featuresTaskId); + + if (featuresTask.status !== TaskStatus.Completed) { + return false; + } + + return Boolean( + featuresTask.last_completed_at && + Date.now() - new Date(featuresTask.last_completed_at).getTime() < + FEATURES_IDENTIFICATION_RECENCY_MS + ); +} + export function createStreamsOnboardingTask(taskContext: TaskContext) { return { [STREAMS_ONBOARDING_TASK_TYPE]: { @@ -64,8 +93,8 @@ export function createStreamsOnboardingTask(taskContext: TaskContext) { } const { fakeRequest } = runContext; - const { streamName, from, to, steps, saveQueries, _task } = runContext.taskInstance - .params as TaskParams; + const { streamName, from, to, steps, saveQueries, connectors, _task } = runContext + .taskInstance.params as TaskParams; const { taskClient, queryClient, streamsClient } = await taskContext.getScopedClients( { @@ -83,21 +112,34 @@ export function createStreamsOnboardingTask(taskContext: TaskContext) { switch (step) { case OnboardingStep.FeaturesIdentification: { const featuresTaskId = getFeaturesIdentificationTaskId(streamName); - - await scheduleFeaturesIdentificationTask( - { - start: from, - end: to, - streamName, - }, - taskClient, - fakeRequest - ); - - featuresTaskResult = await waitForSubtask< - FeaturesIdentificationTaskParams, - IdentifyFeaturesResult - >(featuresTaskId, runContext.taskInstance.id, taskClient); + const isFeaturesOnlyStep = + steps.length === 1 && steps[0] === OnboardingStep.FeaturesIdentification; + + if ( + !isFeaturesOnlyStep && + (await areFeaturesUpToDate({ taskClient, featuresTaskId })) + ) { + featuresTaskResult = await taskClient.getStatus< + FeaturesIdentificationTaskParams, + IdentifyFeaturesResult + >(featuresTaskId); + } else { + await scheduleFeaturesIdentificationTask( + { + start: from, + end: to, + streamName, + connectorId: connectors?.features, + }, + taskClient, + fakeRequest + ); + + featuresTaskResult = await waitForSubtask< + FeaturesIdentificationTaskParams, + IdentifyFeaturesResult + >(featuresTaskId, runContext.taskInstance.id, taskClient); + } if (featuresTaskResult.status !== TaskStatus.Completed) { return; @@ -111,6 +153,7 @@ export function createStreamsOnboardingTask(taskContext: TaskContext) { start: from, end: to, streamName, + connectorId: connectors?.queries, }, taskClient, fakeRequest @@ -140,7 +183,14 @@ export function createStreamsOnboardingTask(taskContext: TaskContext) { await taskClient.complete( _task, - { streamName, from, to, steps, saveQueries }, + { + streamName, + from, + to, + steps, + saveQueries, + connectors, + }, { featuresTaskResult, queriesTaskResult } ); } catch (error) { @@ -168,6 +218,7 @@ export function createStreamsOnboardingTask(taskContext: TaskContext) { to, steps, saveQueries, + connectors, }, errorMessage ); diff --git a/x-pack/platform/plugins/shared/streams/server/routes/internal/sig_events/insights/route.ts b/x-pack/platform/plugins/shared/streams/server/routes/internal/sig_events/insights/route.ts index ecd96a25d6cab..169ee725d2363 100644 --- a/x-pack/platform/plugins/shared/streams/server/routes/internal/sig_events/insights/route.ts +++ b/x-pack/platform/plugins/shared/streams/server/routes/internal/sig_events/insights/route.ts @@ -47,6 +47,12 @@ const insightsTaskRoute = createServerRoute({ .array(z.string()) .describe('List of stream names to generate insights for.') .optional(), + connectorId: z + .string() + .optional() + .describe( + 'Optional connector ID override. When omitted the server resolves the connector from the inference feature registry.' + ), }), }), handler: async ({ params, request, getScopedClients, server }): Promise => { @@ -67,6 +73,7 @@ const insightsTaskRoute = createServerRoute({ taskId: STREAMS_INSIGHTS_DISCOVERY_TASK_TYPE, params: { streamNames: body.streamNames, + connectorId: body.connectorId, }, request, }, diff --git a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/onboarding/route.ts b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/onboarding/route.ts index f3decfed4279f..8e54c1a81f675 100644 --- a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/onboarding/route.ts +++ b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/onboarding/route.ts @@ -53,6 +53,15 @@ export const onboardingTaskRoute = createServerRoute({ .describe( 'Optional list of steps to perform as part of stream onboarding in the specified sequence. By default it will execute all steps.' ), + connectors: z + .object({ + features: z.string().optional().describe('Connector ID for features identification.'), + queries: z.string().optional().describe('Connector ID for queries generation.'), + }) + .optional() + .describe( + 'Optional per-step connector overrides. When omitted the server resolves connectors from the inference feature registry.' + ), }), }), handler: async ({ params, request, getScopedClients, server }): Promise => { @@ -85,6 +94,7 @@ export const onboardingTaskRoute = createServerRoute({ to: body.to, steps: body.steps, saveQueries, + connectors: body.connectors, }, request, }, diff --git a/x-pack/platform/plugins/shared/streams_app/moon.yml b/x-pack/platform/plugins/shared/streams_app/moon.yml index 23af75ff35ad2..95e16d2b44b10 100644 --- a/x-pack/platform/plugins/shared/streams_app/moon.yml +++ b/x-pack/platform/plugins/shared/streams_app/moon.yml @@ -103,6 +103,7 @@ dependsOn: - '@kbn/react-query' - '@kbn/timerange' - '@kbn/inference-common' + - '@kbn/inference-connectors' - '@kbn/esql' - '@kbn/inference-endpoint-ui-common' - '@kbn/stack-connectors-plugin' diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/connector_select_options.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/connector_select_options.tsx new file mode 100644 index 0000000000000..b394a8d29c932 --- /dev/null +++ b/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/connector_select_options.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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { InferenceConnector } from '@kbn/inference-common'; +import React from 'react'; +import { ConnectorIcon } from '../../../../connector_list_button/connector_icon'; + +export interface ConnectorSelectOption { + value: string; + inputDisplay: React.ReactNode; +} + +export const buildConnectorSelectOptions = ( + connectors: InferenceConnector[] +): ConnectorSelectOption[] => + connectors.map((connector) => ({ + value: connector.connectorId, + inputDisplay: ( + + + + + {connector.name} + + ), + })); + +export const getEffectiveConnectorId = ( + displayConnectorId: string | undefined, + options: ConnectorSelectOption[] +): string | undefined => + displayConnectorId && options.some((opt) => opt.value === displayConnectorId) + ? displayConnectorId + : options[0]?.value; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/insights_connector_popover.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/insights_connector_popover.tsx new file mode 100644 index 0000000000000..bf9ac88fd7be2 --- /dev/null +++ b/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/insights_connector_popover.tsx @@ -0,0 +1,126 @@ +/* + * 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, + EuiButtonIcon, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPopover, + EuiPopoverTitle, + EuiSuperSelect, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { InferenceConnector } from '@kbn/inference-common'; +import { useBoolean } from '@kbn/react-hooks'; +import React, { useCallback, useMemo } from 'react'; +import { buildConnectorSelectOptions, getEffectiveConnectorId } from './connector_select_options'; +import { + CONNECTOR_LOAD_ERROR, + INSIGHTS_CONNECTOR_POPOVER_ARIA_LABEL, + INSIGHTS_CONNECTOR_POPOVER_TITLE, + RUN_BUTTON_LABEL, +} from './translations'; + +interface InsightsConnectorPopoverProps { + displayConnectorId: string | undefined; + connectorList: InferenceConnector[]; + connectorError: Error | undefined; + onConnectorChange: (connectorId: string) => void; + onRun: () => void; + isRunDisabled: boolean; +} + +const popoverContentStyle = css` + min-width: 280px; +`; + +export const InsightsConnectorPopover = ({ + displayConnectorId, + connectorList, + connectorError, + onConnectorChange, + onRun, + isRunDisabled, +}: InsightsConnectorPopoverProps) => { + const [isOpen, { off: close, toggle }] = useBoolean(false); + const popoverId = useGeneratedHtmlId({ prefix: 'insightsConnectorPopover' }); + const selectId = useGeneratedHtmlId({ prefix: 'insightsConnectorSelect' }); + + const connectorOptions = useMemo( + () => buildConnectorSelectOptions(connectorList), + [connectorList] + ); + const effectiveConnectorId = useMemo( + () => getEffectiveConnectorId(displayConnectorId, connectorOptions), + [displayConnectorId, connectorOptions] + ); + + const handleRun = useCallback(() => { + close(); + onRun(); + }, [close, onRun]); + + return ( + + } + panelPaddingSize="m" + > + {INSIGHTS_CONNECTOR_POPOVER_TITLE} + + {connectorError ? ( + + + + ) : ( + effectiveConnectorId && + connectorOptions.length > 0 && ( + + + + + + ) + )} + + + {RUN_BUTTON_LABEL} + + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/onboarding_config_popover.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/onboarding_config_popover.tsx new file mode 100644 index 0000000000000..57ad87b53e823 --- /dev/null +++ b/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/onboarding_config_popover.tsx @@ -0,0 +1,218 @@ +/* + * 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, + EuiButtonIcon, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPopover, + EuiPopoverTitle, + EuiSuperSelect, + EuiSwitch, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { InferenceConnector } from '@kbn/inference-common'; +import { useBoolean } from '@kbn/react-hooks'; +import { OnboardingStep } from '@kbn/streams-schema'; +import React, { useCallback, useMemo } from 'react'; +import { buildConnectorSelectOptions, getEffectiveConnectorId } from './connector_select_options'; +import { + CONNECTOR_LOAD_ERROR, + FEATURES_STEP_LABEL, + ONBOARDING_CONFIG_POPOVER_ARIA_LABEL, + ONBOARDING_CONFIG_POPOVER_TITLE, + QUERIES_STEP_LABEL, + RUN_BUTTON_LABEL, +} from './translations'; + +export interface OnboardingConfig { + steps: OnboardingStep[]; + connectors: { + features?: string; + queries?: string; + }; +} + +const STEP_CONNECTOR_KEY: Record = { + [OnboardingStep.FeaturesIdentification]: 'features', + [OnboardingStep.QueriesGeneration]: 'queries', +}; + +const STEPS: ReadonlyArray<{ step: OnboardingStep; label: string }> = [ + { step: OnboardingStep.FeaturesIdentification, label: FEATURES_STEP_LABEL }, + { step: OnboardingStep.QueriesGeneration, label: QUERIES_STEP_LABEL }, +]; + +interface StepRowProps { + step: OnboardingStep; + label: string; + enabled: boolean; + displayConnectorId: string | undefined; + connectorList: InferenceConnector[]; + connectorError: Error | undefined; + onToggle: (step: OnboardingStep, enabled: boolean) => void; + onConnectorChange: (step: OnboardingStep, connectorId: string) => void; +} + +const StepRow = ({ + step, + label, + enabled, + displayConnectorId, + connectorList, + connectorError, + onToggle, + onConnectorChange, +}: StepRowProps) => { + const selectId = useGeneratedHtmlId({ prefix: `onboardingStep_${step}` }); + + const connectorOptions = useMemo( + () => buildConnectorSelectOptions(connectorList), + [connectorList] + ); + const effectiveConnectorId = useMemo( + () => getEffectiveConnectorId(displayConnectorId, connectorOptions), + [displayConnectorId, connectorOptions] + ); + + return ( + + + onToggle(step, e.target.checked)} + compressed + /> + + {connectorError ? ( + + + + ) : ( + effectiveConnectorId && + connectorOptions.length > 0 && ( + + + onConnectorChange(step, value)} + disabled={!enabled} + compressed + fullWidth + /> + + + ) + )} + + ); +}; + +interface OnboardingConfigPopoverProps { + config: OnboardingConfig; + displayConnectors: OnboardingConfig['connectors']; + connectorList: InferenceConnector[]; + connectorError: Error | undefined; + onConfigChange: (config: OnboardingConfig) => void; + onRun: () => void; + isRunDisabled: boolean; +} + +const popoverContentStyle = css` + min-width: 280px; +`; + +export const OnboardingConfigPopover = ({ + config, + displayConnectors, + connectorList, + connectorError, + onConfigChange, + onRun, + isRunDisabled, +}: OnboardingConfigPopoverProps) => { + const [isOpen, { off: close, toggle }] = useBoolean(false); + const popoverId = useGeneratedHtmlId({ prefix: 'onboardingConfigPopover' }); + + const handleToggle = useCallback( + (step: OnboardingStep, enabled: boolean) => { + const toggled = enabled ? [...config.steps, step] : config.steps.filter((s) => s !== step); + const ordered = STEPS.map((s) => s.step).filter((s) => toggled.includes(s)); + onConfigChange({ ...config, steps: ordered }); + }, + [config, onConfigChange] + ); + + const handleConnectorChange = useCallback( + (step: OnboardingStep, newConnectorId: string) => { + const key = STEP_CONNECTOR_KEY[step]; + onConfigChange({ ...config, connectors: { ...config.connectors, [key]: newConnectorId } }); + }, + [config, onConfigChange] + ); + + const handleRun = useCallback(() => { + close(); + onRun(); + }, [close, onRun]); + + return ( + + } + panelPaddingSize="m" + > + {ONBOARDING_CONFIG_POPOVER_TITLE} + + {STEPS.map(({ step, label }) => ( + + + + ))} + + + {RUN_BUTTON_LABEL} + + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/streams_view.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/streams_view.tsx index dec1eb00ffa32..ee6acdcec34ad 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/streams_view.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/streams_view.tsx @@ -23,16 +23,19 @@ import pMap from 'p-map'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import useAsyncFn from 'react-use/lib/useAsyncFn'; import type { TableRow } from './utils'; -import { useAIFeatures } from '../../../../../hooks/use_ai_features'; import { useIndexPatternsConfig } from '../../../../../hooks/use_index_patterns_config'; import { useKibana } from '../../../../../hooks/use_kibana'; import { useInsightsDiscoveryApi } from '../../../../../hooks/sig_events/use_insights_discovery_api'; +import { useConnectorConfig } from '../../../../../hooks/sig_events/use_connector_config'; +import type { ScheduleOnboardingOptions } from '../../../../../hooks/use_onboarding_api'; import { useOnboardingApi } from '../../../../../hooks/use_onboarding_api'; import { useStreamsAppRouter } from '../../../../../hooks/use_streams_app_router'; import { useTaskPolling } from '../../../../../hooks/use_task_polling'; import { getFormattedError } from '../../../../../util/errors'; import { StreamsAppSearchBar } from '../../../../streams_app_search_bar'; import { useOnboardingStatusUpdateQueue } from '../../hooks/use_onboarding_status_update_queue'; +import { InsightsConnectorPopover } from './insights_connector_popover'; +import { OnboardingConfigPopover } from './onboarding_config_popover'; import { DISCOVER_INSIGHTS_BUTTON_LABEL, getInsightsCompleteToastTitle, @@ -41,12 +44,15 @@ import { NO_INSIGHTS_TOAST_TITLE, ONBOARDING_FAILURE_TITLE, ONBOARDING_SCHEDULING_FAILURE_TITLE, - RUN_BULK_STREAM_ONBOARDING_BUTTON_LABEL, STREAMS_TABLE_SEARCH_ARIA_LABEL, } from './translations'; import { StreamsTreeTable } from './tree_table'; import { useFetchStreams } from '../../hooks/use_fetch_streams'; +const onboardingButtonStyle = css` + min-width: 160px; +`; + const datePickerStyle = css` .euiFormControlLayout, .euiSuperDatePicker button, @@ -71,6 +77,21 @@ export function StreamsView({ refreshUnbackedQueriesCount }: StreamsViewProps) { const [isWaitingForInsightsTask, setIsWaitingForInsightsTask] = useState(false); const { filterStreamsByIndexPatterns } = useIndexPatternsConfig(); + const { + featuresConnectors, + queriesConnectors, + discoveryConnectors, + genAiConnectors, + isConnectorCatalogUnavailable, + discoveryConnectorOverride, + setDiscoveryConnectorOverride, + displayDiscoveryConnectorId, + onboardingConfig, + setOnboardingConfig, + displayConnectors, + dynamicButtonLabel, + } = useConnectorConfig(); + const streamsListFetch = useFetchStreams({ select: (result) => { return { @@ -88,7 +109,6 @@ export function StreamsView({ refreshUnbackedQueriesCount }: StreamsViewProps) { Record> >({}); const router = useStreamsAppRouter(); - const aiFeatures = useAIFeatures(); const { scheduleOnboardingTask, cancelOnboardingTask } = useOnboardingApi(); const { scheduleInsightsDiscoveryTask, getInsightsDiscoveryTaskStatus } = useInsightsDiscoveryApi(); @@ -105,7 +125,7 @@ export function StreamsView({ refreshUnbackedQueriesCount }: StreamsViewProps) { const streamNames = selectedStreams.length > 0 ? selectedStreams.map((row) => row.stream.name) : undefined; try { - await scheduleInsightsDiscoveryTask(streamNames); + await scheduleInsightsDiscoveryTask(streamNames, discoveryConnectorOverride); setIsWaitingForInsightsTask(true); await getInsightsTaskStatus(); } catch (error) { @@ -114,7 +134,13 @@ export function StreamsView({ refreshUnbackedQueriesCount }: StreamsViewProps) { }); throw error; } - }, [scheduleInsightsDiscoveryTask, selectedStreams, toasts, getInsightsTaskStatus]); + }, [ + scheduleInsightsDiscoveryTask, + selectedStreams, + discoveryConnectorOverride, + toasts, + getInsightsTaskStatus, + ]); // When we started the insights task from this view and it completes, show toast useEffect(() => { @@ -211,45 +237,47 @@ export function StreamsView({ refreshUnbackedQueriesCount }: StreamsViewProps) { }); }, [onboardingStatusUpdateQueue, processStatusUpdateQueue, streamsListFetch.data]); - const bulkScheduleOnboardingTask = async (streamList: string[]) => { + const isStreamActionable = (streamName: string) => { + const result = streamOnboardingResultMap[streamName]; + if (!result) return false; + return ![TaskStatus.InProgress, TaskStatus.BeingCanceled].includes(result.status); + }; + + const getActionableStreamNames = () => + selectedStreams + .filter((item) => isStreamActionable(item.stream.name)) + .map((item) => item.stream.name); + + const bulkScheduleOnboardingTask = async ( + streamList: string[], + options?: ScheduleOnboardingOptions + ) => { try { await pMap( streamList, async (streamName) => { - await scheduleOnboardingTask(streamName); + await scheduleOnboardingTask(streamName, options); }, { concurrency: 10 } ); } catch (error) { - toasts.addError(getFormattedError(error), { - title: ONBOARDING_SCHEDULING_FAILURE_TITLE, - }); + toasts.addError(getFormattedError(error), { title: ONBOARDING_SCHEDULING_FAILURE_TITLE }); } - }; - const onBulkOnboardStreamsClick = async () => { - const streamList = selectedStreams - .filter((item) => { - const onboardingResult = streamOnboardingResultMap[item.stream.name]; - - return ![TaskStatus.InProgress, TaskStatus.BeingCanceled].includes(onboardingResult.status); - }) - .map((item) => item.stream.name); - - setSelectedStreams([]); - - await bulkScheduleOnboardingTask(streamList); streamList.forEach((streamName) => { onboardingStatusUpdateQueue.add(streamName); }); processStatusUpdateQueue(); }; + const onBulkOnboardStreamsClick = async () => { + const streamList = getActionableStreamNames(); + setSelectedStreams([]); + await bulkScheduleOnboardingTask(streamList, onboardingConfig); + }; + const onOnboardStreamActionClick = async (streamName: string) => { await bulkScheduleOnboardingTask([streamName]); - - onboardingStatusUpdateQueue.add(streamName); - processStatusUpdateQueue(); }; const onStopOnboardingActionClick = (streamName: string) => { @@ -288,25 +316,70 @@ export function StreamsView({ refreshUnbackedQueriesCount }: StreamsViewProps) { )} - - {RUN_BULK_STREAM_ONBOARDING_BUTTON_LABEL} - + + + + + {dynamicButtonLabel} + + + + + + + - scheduleInsightsTask()} - disabled={!aiFeatures?.genAiConnectors?.connectors?.length} - isLoading={isSchedulingInsights || isWaitingForInsightsTask} - data-test-subj="significant_events_discover_insights_button" - size="xs" - > - {DISCOVER_INSIGHTS_BUTTON_LABEL} - + + + + scheduleInsightsTask()} + disabled={isConnectorCatalogUnavailable} + isLoading={isSchedulingInsights || isWaitingForInsightsTask} + data-test-subj="significant_events_discover_insights_button" + size="xs" + > + {DISCOVER_INSIGHTS_BUTTON_LABEL} + + + + + + + @@ -316,7 +389,11 @@ export function StreamsView({ refreshUnbackedQueriesCount }: StreamsViewProps) { streamOnboardingResultMap={streamOnboardingResultMap} loading={streamsListFetch.isLoading} searchQuery={searchQuery} - selection={{ selected: selectedStreams, onSelectionChange: setSelectedStreams }} + selection={{ + selected: selectedStreams, + onSelectionChange: setSelectedStreams, + selectable: (row) => isStreamActionable(row.stream.name), + }} onOnboardStreamActionClick={onOnboardStreamActionClick} onStopOnboardingActionClick={onStopOnboardingActionClick} /> diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/translations.ts b/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/translations.ts index 4e7cc23c3d1d2..293cef0d4fc54 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/translations.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/sig_events/significant_events_discovery/components/streams_view/translations.ts @@ -21,14 +21,14 @@ export const SIGNIFICANT_EVENTS_COLUMN_HEADER = i18n.translate( export const QUERIES_COLUMN_HEADER = i18n.translate( 'xpack.streams.significantEventsDiscovery.streamsTree.queriesColumnName', { - defaultMessage: 'Queries', + defaultMessage: 'KI Queries', } ); export const KNOWLEDGE_INDICATORS_COLUMN_HEADER = i18n.translate( 'xpack.streams.significantEventsDiscovery.streamsTree.knowledgeIndicatorsColumnName', { - defaultMessage: 'Knowledge Indicators', + defaultMessage: 'KI Features', } ); @@ -145,3 +145,73 @@ export const NO_INSIGHTS_TOAST_TITLE = i18n.translate( defaultMessage: 'No Significant Events found', } ); + +export const GENERATE_FEATURES_BUTTON_LABEL = i18n.translate( + 'xpack.streams.significantEventsDiscovery.streamsView.generateFeaturesButtonLabel', + { + defaultMessage: 'Generate KI Features', + } +); + +export const GENERATE_QUERIES_BUTTON_LABEL = i18n.translate( + 'xpack.streams.significantEventsDiscovery.streamsView.generateQueriesButtonLabel', + { + defaultMessage: 'Generate KI Queries', + } +); + +export const ONBOARDING_CONFIG_POPOVER_ARIA_LABEL = i18n.translate( + 'xpack.streams.significantEventsDiscovery.streamsView.onboardingConfigPopoverAriaLabel', + { + defaultMessage: 'Configure onboarding steps and connectors', + } +); + +export const ONBOARDING_CONFIG_POPOVER_TITLE = i18n.translate( + 'xpack.streams.significantEventsDiscovery.streamsView.onboardingConfigPopoverTitle', + { + defaultMessage: 'Onboarding configuration', + } +); + +export const FEATURES_STEP_LABEL = i18n.translate( + 'xpack.streams.significantEventsDiscovery.streamsView.featuresStepLabel', + { + defaultMessage: 'KI Features', + } +); + +export const QUERIES_STEP_LABEL = i18n.translate( + 'xpack.streams.significantEventsDiscovery.streamsView.queriesStepLabel', + { + defaultMessage: 'KI Queries', + } +); + +export const RUN_BUTTON_LABEL = i18n.translate( + 'xpack.streams.significantEventsDiscovery.streamsView.runButtonLabel', + { + defaultMessage: 'Run', + } +); + +export const INSIGHTS_CONNECTOR_POPOVER_ARIA_LABEL = i18n.translate( + 'xpack.streams.significantEventsDiscovery.streamsView.insightsConnectorPopoverAriaLabel', + { + defaultMessage: 'Configure discovery connector', + } +); + +export const INSIGHTS_CONNECTOR_POPOVER_TITLE = i18n.translate( + 'xpack.streams.significantEventsDiscovery.streamsView.insightsConnectorPopoverTitle', + { + defaultMessage: 'Discovery connector', + } +); + +export const CONNECTOR_LOAD_ERROR = i18n.translate( + 'xpack.streams.significantEventsDiscovery.streamsView.connectorLoadError', + { + defaultMessage: 'Failed to load connectors', + } +); diff --git a/x-pack/platform/plugins/shared/streams_app/public/hooks/sig_events/use_connector_config.ts b/x-pack/platform/plugins/shared/streams_app/public/hooks/sig_events/use_connector_config.ts new file mode 100644 index 0000000000000..7e0c42757ec22 --- /dev/null +++ b/x-pack/platform/plugins/shared/streams_app/public/hooks/sig_events/use_connector_config.ts @@ -0,0 +1,86 @@ +/* + * 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 { + OnboardingStep, + STREAMS_SIG_EVENTS_DISCOVERY_INFERENCE_FEATURE_ID, + STREAMS_SIG_EVENTS_KI_EXTRACTION_INFERENCE_FEATURE_ID, + STREAMS_SIG_EVENTS_KI_QUERY_GENERATION_INFERENCE_FEATURE_ID, +} from '@kbn/streams-schema'; +import { useMemo, useState } from 'react'; +import type { OnboardingConfig } from '../../components/sig_events/significant_events_discovery/components/streams_view/onboarding_config_popover'; +import { + GENERATE_FEATURES_BUTTON_LABEL, + GENERATE_QUERIES_BUTTON_LABEL, + RUN_BULK_STREAM_ONBOARDING_BUTTON_LABEL, +} from '../../components/sig_events/significant_events_discovery/components/streams_view/translations'; +import { useAIFeatures } from '../use_ai_features'; +import { useInferenceFeatureConnectors } from './use_inference_feature_connectors'; + +export function useConnectorConfig() { + const featuresConnectors = useInferenceFeatureConnectors( + STREAMS_SIG_EVENTS_KI_EXTRACTION_INFERENCE_FEATURE_ID + ); + const queriesConnectors = useInferenceFeatureConnectors( + STREAMS_SIG_EVENTS_KI_QUERY_GENERATION_INFERENCE_FEATURE_ID + ); + const discoveryConnectors = useInferenceFeatureConnectors( + STREAMS_SIG_EVENTS_DISCOVERY_INFERENCE_FEATURE_ID + ); + + const aiFeatures = useAIFeatures(); + const genAiConnectors = aiFeatures?.genAiConnectors; + const isConnectorCatalogUnavailable = + !genAiConnectors?.connectors?.length || !!genAiConnectors?.loading || !!genAiConnectors?.error; + + const [discoveryConnectorOverride, setDiscoveryConnectorOverride] = useState< + string | undefined + >(); + const displayDiscoveryConnectorId = + discoveryConnectorOverride ?? discoveryConnectors.resolvedConnectorId; + + const [onboardingConfig, setOnboardingConfig] = useState({ + steps: [OnboardingStep.FeaturesIdentification, OnboardingStep.QueriesGeneration], + connectors: {}, + }); + + const displayConnectors = useMemo( + () => ({ + features: onboardingConfig.connectors.features ?? featuresConnectors.resolvedConnectorId, + queries: onboardingConfig.connectors.queries ?? queriesConnectors.resolvedConnectorId, + }), + [ + onboardingConfig.connectors, + featuresConnectors.resolvedConnectorId, + queriesConnectors.resolvedConnectorId, + ] + ); + + const { steps: selectedSteps } = onboardingConfig; + const dynamicButtonLabel = useMemo(() => { + const hasFeatures = selectedSteps.includes(OnboardingStep.FeaturesIdentification); + const hasQueries = selectedSteps.includes(OnboardingStep.QueriesGeneration); + if (hasFeatures && !hasQueries) return GENERATE_FEATURES_BUTTON_LABEL; + if (hasQueries && !hasFeatures) return GENERATE_QUERIES_BUTTON_LABEL; + return RUN_BULK_STREAM_ONBOARDING_BUTTON_LABEL; + }, [selectedSteps]); + + return { + featuresConnectors, + queriesConnectors, + discoveryConnectors, + genAiConnectors, + isConnectorCatalogUnavailable, + discoveryConnectorOverride, + setDiscoveryConnectorOverride, + displayDiscoveryConnectorId, + onboardingConfig, + setOnboardingConfig, + displayConnectors, + dynamicButtonLabel, + }; +} diff --git a/x-pack/platform/plugins/shared/streams_app/public/hooks/sig_events/use_inference_feature_connectors.ts b/x-pack/platform/plugins/shared/streams_app/public/hooks/sig_events/use_inference_feature_connectors.ts new file mode 100644 index 0000000000000..e5437d8220b92 --- /dev/null +++ b/x-pack/platform/plugins/shared/streams_app/public/hooks/sig_events/use_inference_feature_connectors.ts @@ -0,0 +1,34 @@ +/* + * 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 { useLoadConnectors } from '@kbn/inference-connectors'; +import { useKibana } from '../use_kibana'; + +export interface UseInferenceFeatureConnectorsResult { + resolvedConnectorId: string | undefined; + loading: boolean; + error: Error | undefined; +} + +export function useInferenceFeatureConnectors( + featureId: string +): UseInferenceFeatureConnectorsResult { + const { core } = useKibana(); + + const query = useLoadConnectors({ + http: core.http, + toasts: core.notifications.toasts, + featureId, + settings: core.settings, + }); + + return { + resolvedConnectorId: query.data?.[0]?.id, + loading: query.isLoading, + error: query.error ?? undefined, + }; +} diff --git a/x-pack/platform/plugins/shared/streams_app/public/hooks/sig_events/use_insights_discovery_api.ts b/x-pack/platform/plugins/shared/streams_app/public/hooks/sig_events/use_insights_discovery_api.ts index 96139191c8e43..61ef9c0fad2b7 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/hooks/sig_events/use_insights_discovery_api.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/hooks/sig_events/use_insights_discovery_api.ts @@ -22,13 +22,14 @@ export function useInsightsDiscoveryApi() { return useMemo( () => ({ - scheduleInsightsDiscoveryTask: async (streamNames?: string[]) => { + scheduleInsightsDiscoveryTask: async (streamNames?: string[], connectorId?: string) => { await streamsRepositoryClient.fetch('POST /internal/streams/_insights/_task', { signal, params: { body: { action: 'schedule', ...(streamNames && streamNames.length > 0 ? { streamNames } : {}), + ...(connectorId !== undefined && { connectorId }), }, }, }); diff --git a/x-pack/platform/plugins/shared/streams_app/public/hooks/use_onboarding_api.ts b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_onboarding_api.ts index a961a8761caf5..15b1d6ac0ecfd 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/hooks/use_onboarding_api.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_onboarding_api.ts @@ -6,6 +6,7 @@ */ import { useAbortController } from '@kbn/react-hooks'; +import type { OnboardingStep } from '@kbn/streams-schema'; import { useMemo } from 'react'; import { useKibana } from './use_kibana'; import { getLast24HoursTimeRange } from '../util/time_range'; @@ -14,6 +15,14 @@ export interface UseOnboardingApiOptions { saveQueries?: boolean; } +export interface ScheduleOnboardingOptions { + steps?: OnboardingStep[]; + connectors?: { + features?: string; + queries?: string; + }; +} + export function useOnboardingApi({ saveQueries = true }: UseOnboardingApiOptions = {}) { const { dependencies: { @@ -27,7 +36,7 @@ export function useOnboardingApi({ saveQueries = true }: UseOnboardingApiOptions return useMemo( () => ({ - scheduleOnboardingTask: async (streamName: string) => { + scheduleOnboardingTask: async (streamName: string, options?: ScheduleOnboardingOptions) => { const { from, to } = getLast24HoursTimeRange(); return streamsRepositoryClient.fetch( @@ -41,6 +50,8 @@ export function useOnboardingApi({ saveQueries = true }: UseOnboardingApiOptions action: 'schedule' as const, from, to, + ...(options?.steps !== undefined && { steps: options.steps }), + ...(options?.connectors !== undefined && { connectors: options.connectors }), }, }, } diff --git a/x-pack/platform/plugins/shared/streams_app/tsconfig.json b/x-pack/platform/plugins/shared/streams_app/tsconfig.json index d7d2058442722..0bf1a578fcfe0 100644 --- a/x-pack/platform/plugins/shared/streams_app/tsconfig.json +++ b/x-pack/platform/plugins/shared/streams_app/tsconfig.json @@ -104,6 +104,7 @@ "@kbn/react-query", "@kbn/timerange", "@kbn/inference-common", + "@kbn/inference-connectors", "@kbn/esql", "@kbn/inference-endpoint-ui-common", "@kbn/stack-connectors-plugin",