diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_chart.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_chart.tsx new file mode 100644 index 0000000000000..d8a12d5e43563 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_chart.tsx @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingChart } from '@elastic/eui'; +import { getESQLAdHocDataview } from '@kbn/esql-utils'; +import { esFieldTypeToKibanaFieldType } from '@kbn/field-types'; +import type { TypedLensByValueInput, LensPublicStart } from '@kbn/lens-plugin/public'; +import type { DatatableColumn } from '@kbn/expressions-plugin/common'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { getLensAttributesFromSuggestion, ChartType } from '@kbn/visualization-utils'; +import type { QueryColumn } from './use_query_execution'; + +const CHART_HEIGHT = 180; + +interface ComposeDiscoverChartProps { + query: string; + timeField: string; + timeRange: { from: string; to: string }; + columns: QueryColumn[]; + lens: LensPublicStart; + dataViews: DataViewsPublicPluginStart; +} + +const toDatatableColumns = (columns: QueryColumn[]): DatatableColumn[] => + columns.map( + (col) => + ({ + id: col.id, + name: col.id, + meta: { type: esFieldTypeToKibanaFieldType(col.esType), esType: col.esType }, + } as DatatableColumn) + ); + +export const ComposeDiscoverChart: React.FC = ({ + query, + timeField, + timeRange, + columns, + lens, + dataViews, +}) => { + const [lensAttributes, setLensAttributes] = useState< + TypedLensByValueInput['attributes'] | undefined + >(undefined); + const [isLoading, setIsLoading] = useState(false); + + const datatableColumnsRef = useRef([]); + datatableColumnsRef.current = useMemo(() => toDatatableColumns(columns), [columns]); + + useEffect(() => { + if (!query.trim() || !timeField.trim() || columns.length === 0) { + setLensAttributes(undefined); + return; + } + + let cancelled = false; + setIsLoading(true); + + const run = async () => { + try { + const { suggestions } = await lens.stateHelperApi(); + if (cancelled) return; + + const adHocDataView = await getESQLAdHocDataview({ + dataViewsService: dataViews, + query, + }); + if (cancelled) return; + + adHocDataView.timeFieldName = timeField; + + const context = { + dataViewSpec: adHocDataView.toSpec(), + fieldName: '', + textBasedColumns: datatableColumnsRef.current, + query: { esql: query }, + }; + + const allSuggestions = suggestions( + context, + adHocDataView, + ['lnsDatatable'], + ChartType.Bar + ) ?? []; + + const chartSuggestions = allSuggestions.filter( + (s) => s.visualizationId && s.visualizationId !== 'lnsDatatable' + ); + + if (!cancelled) { + if (chartSuggestions[0]) { + const attrs = getLensAttributesFromSuggestion({ + filters: [], + query: { esql: query }, + suggestion: chartSuggestions[0], + dataView: adHocDataView, + }); + setLensAttributes(attrs as TypedLensByValueInput['attributes']); + } else { + setLensAttributes(undefined); + } + setIsLoading(false); + } + } catch { + if (!cancelled) { + setLensAttributes(undefined); + setIsLoading(false); + } + } + }; + + run(); + return () => { + cancelled = true; + }; + }, [query, timeField, columns, lens, dataViews]); + + if (isLoading && !lensAttributes) { + return ( + + + + + + ); + } + + if (!lensAttributes) return null; + + const LensComponent = lens.EmbeddableComponent; + + return ( +
+ +
+ ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_child.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_child.tsx new file mode 100644 index 0000000000000..7e54b38d435e5 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_child.tsx @@ -0,0 +1,437 @@ +/* + * 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, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiTitle, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiLoadingSpinner, + EuiTab, + EuiTabs, + EuiText, + EuiCallOut, + EuiEmptyPrompt, + EuiToolTip, + EuiSuperDatePicker, + EuiSelect, + EuiPanel, + EuiDataGrid, + type EuiDataGridColumn, + type EuiDataGridCellValueElementProps, +} from '@elastic/eui'; +import { CodeEditor } from '@kbn/code-editor'; +import { ESQL_LANG_ID } from '@kbn/monaco'; +import type { RuleFormServices } from '../../form/contexts/rule_form_context'; +import { useDataFields } from '../../form/hooks/use_data_fields'; +import type { ComposeDiscoverState, ComposeDiscoverAction, SandboxTabConfig } from './types'; +import { ComposeDiscoverTabs } from './compose_discover_tabs'; +import { useQueryExecution } from './use_query_execution'; +import { useEsqlAutocomplete } from './use_esql_providers'; +import { ComposeDiscoverChart } from './compose_discover_chart'; + +interface ComposeDiscoverChildProps { + state: ComposeDiscoverState; + dispatch: React.Dispatch; + services: RuleFormServices; + /** Determines which tabs to show in the editor. Computed by getSandboxTabConfig() in the parent. */ + tabConfig: SandboxTabConfig; + + onClose: () => void; +} + +const CHILD_FLYOUT_TITLE_ID = 'composeDiscoverChildTitle'; +const VISIBLE_ROWS = 10; +const INITIAL_EDITOR_HEIGHT = 200; +const MIN_EDITOR_HEIGHT = 80; +const MAX_EDITOR_HEIGHT = 600; + +const isMac = + typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent); +const RUN_SHORTCUT_LABEL = isMac ? '⌘⏎' : 'Ctrl+Enter'; + +/** Mirrors getVisibleTabIds from compose_discover_tabs for inline header tab bar. */ +function getVisibleTabIdsForHeader(tabConfig: SandboxTabConfig): Array<'base' | 'alert' | 'recovery'> { + switch (tabConfig.type) { + case 'base-alert': return ['base', 'alert']; + case 'base-recovery': return ['recovery']; + case 'all-three': return ['base', 'alert', 'recovery']; + default: return []; + } +} + +function getActiveQuery( + state: ComposeDiscoverState, + localQuery: string, + tabConfig: SandboxTabConfig +): string { + if (tabConfig.type === 'single') return localQuery; + switch (state.activeTab) { + case 'base': + return state.baseQuery; + case 'alert': + return [state.baseQuery, state.alertBlock].filter(Boolean).join('\n'); + case 'recovery': + return [state.baseQuery, state.recoveryBlock].filter(Boolean).join('\n'); + default: + return [state.baseQuery, state.alertBlock].filter(Boolean).join('\n'); + } +} + +export const ComposeDiscoverChild: React.FC = ({ + state, + dispatch, + services, + tabConfig, + + onClose, +}) => { + useEsqlAutocomplete(services); + + const [localQuery, setLocalQuery] = useState(state.fullQuery); + const [dateStart, setDateStart] = useState('now-15m'); + const [dateEnd, setDateEnd] = useState('now'); + + const timeRange = useMemo(() => ({ from: dateStart, to: dateEnd }), [dateStart, dateEnd]); + + const activeQuery = getActiveQuery(state, localQuery, tabConfig); + + // Only fetch fields when the query has a real index pattern after FROM. + // Guard must check for a valid index name character — NOT just any non-whitespace, + // because "FROM \n| STATS..." has a pipe after the newline which is not a source. + const queryForFields = /^\s*FROM\s+[a-zA-Z0-9_.*-]/i.test(activeQuery) ? activeQuery : ''; + const { data: fieldMap } = useDataFields({ + query: queryForFields, + http: services.http, + dataViews: services.dataViews, + }); + + const timeFieldOptions = useMemo(() => { + const dateFields = Object.values(fieldMap) + .filter((f) => f.type === 'date') + .map((f) => f.name) + .sort(); + + if (!dateFields.includes('@timestamp')) { + dateFields.unshift('@timestamp'); + } + + return dateFields.map((name) => ({ value: name, text: name })); + }, [fieldMap]); + + const { columns, rows, totalRowCount, isLoading, isError, error, run, hasRun, lastExecutedQuery } = + useQueryExecution({ + query: activeQuery, + timeField: state.timeField, + timeRange, + data: services.data, + }); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); // stops the event reaching Monaco's own handlers + run(); + } + }; + // Capture phase so we intercept before Monaco handles ⌘↵ for its own commands + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [run]); + + const handleDone = useCallback(() => { + if (tabConfig.type !== 'single') { + dispatch({ + type: 'COMMIT_CHILD_SPLIT', + baseQuery: state.baseQuery, + alertBlock: state.alertBlock, + recoveryBlock: state.recoveryBlock, + }); + } else { + dispatch({ type: 'COMMIT_CHILD_QUERY', fullQuery: localQuery }); + } + onClose(); + }, [tabConfig, state, localQuery, dispatch, onClose]); + + const gridColumns: EuiDataGridColumn[] = useMemo( + () => + columns.map((col) => ({ + id: col.id, + displayAsText: col.displayAsText, + schema: col.esType, + })), + [columns] + ); + + const [visibleColumns, setVisibleColumns] = useState([]); + + const prevColumnIdsRef = useRef(''); + useEffect(() => { + const ids = columns.map((c) => c.id).join(','); + if (ids !== prevColumnIdsRef.current) { + prevColumnIdsRef.current = ids; + setVisibleColumns(columns.map((c) => c.id)); + } + }, [columns]); + + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: VISIBLE_ROWS }); + const onChangePage = useCallback((pageIndex: number) => { + setPagination((prev) => ({ ...prev, pageIndex })); + }, []); + const onChangeItemsPerPage = useCallback((pageSize: number) => { + setPagination({ pageIndex: 0, pageSize }); + }, []); + + const renderCellValue = useCallback( + ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { + const row = rows[rowIndex]; + if (!row) return null; + const value = row[columnId]; + return <>{value ?? '—'}; + }, + [rows] + ); + + const editorPanelStyles: React.CSSProperties = useMemo( + () => ({ + resize: 'vertical', + overflow: 'auto', + height: INITIAL_EDITOR_HEIGHT, + minHeight: MIN_EDITOR_HEIGHT, + maxHeight: MAX_EDITOR_HEIGHT, + }), + [] + ); + + return ( + + + +

+ {!state.queryCommitted + ? 'Define alert query' + : tabConfig.type === 'base-recovery' + ? 'Edit recovery query' + : tabConfig.type !== 'single' + ? 'Edit rule queries' + : 'Edit alert query'} +

+
+ {!state.queryCommitted && ( + + Write the ES|QL query that defines when this rule should alert. You'll configure + the rest of the rule settings next. + + )} +
+ + + {/* ── 1. Tab bar ──────────────────────────────────────────────── */} +
+ {tabConfig.type !== 'single' ? ( + + {(['base', 'alert', 'recovery'] as const) + .filter((id) => getVisibleTabIdsForHeader(tabConfig).includes(id)) + .map((id) => ( + dispatch({ type: 'SET_TAB', tab: id })} + data-test-subj={`composeDiscoverTab-${id}`} + > + {id === 'base' ? 'Base query' : id === 'alert' ? 'Alert query' : 'Recovery query'} + + ))} + + ) : ( + + Query + + )} +
+ + {/* ── 2. Search / date picker / time field row — one line ──────── */} +
+ + + + + Search + + + + + { + setDateStart(start); + setDateEnd(end); + }} + showUpdateButton={false} + compressed + width="full" + /> + + + + dispatch({ type: 'SET_TIME_FIELD', timeField: e.target.value }) + } + compressed + prepend="Time field" + data-test-subj="composeDiscoverTimeField" + /> + + +
+ + {/* ── 3. Editor — bordered panel ──────────────────────────────── */} + + {tabConfig.type !== 'single' ? ( + + ) : ( + + )} + + + {/* ── 4. Footer stats ─────────────────────────────────────────── */} + {hasRun && !isLoading && !isError && ( +
+ + {totalRowCount.toLocaleString()} document{totalRowCount !== 1 ? 's' : ''} queried + +
+ )} + + {/* ── 5. Results ──────────────────────────────────────────────── */} +
+ + + {!hasRun && ( + Run your query to see results} + body={ +

+ Click Search or press {RUN_SHORTCUT_LABEL} to + execute the query. +

+ } + /> + )} + + {hasRun && isLoading && ( + + + + + + )} + + {hasRun && isError && ( + +

{error}

+
+ )} + + {hasRun && !isLoading && !isError && rows.length === 0 && activeQuery.trim() && ( + No results} + body={

The query returned no results for the current time range.

} + /> + )} + + {hasRun && !isLoading && !isError && rows.length > 0 && ( + <> + + + + + + + + {totalRowCount} {totalRowCount === 1 ? 'result' : 'results'} + + + + + + + + )} +
+
+ + + + + + Apply changes + + + + +
+ ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_flyout.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_flyout.tsx new file mode 100644 index 0000000000000..71e749f4ffb3d --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_flyout.tsx @@ -0,0 +1,194 @@ +/* + * 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 { + EuiBadge, + EuiButton, + EuiButtonEmpty, + EuiButtonGroup, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { HorizontalMinimalStepper, type MinimalStep } from './horizontal_minimal_stepper'; +import type { RuleFormServices } from '../../form/contexts/rule_form_context'; +import type { ComposeDiscoverMode } from './types'; +import { useComposeDiscoverState, getStepTitles, getSandboxTabConfig } from './use_compose_discover_state'; +import { ComposeDiscoverForm } from './compose_discover_form'; +import { ComposeDiscoverChild } from './compose_discover_child'; + +export interface ComposeDiscoverFlyoutProps { + historyKey: symbol; + mode?: ComposeDiscoverMode; + initialQuery?: string; + onClose: () => void; + services: RuleFormServices; +} + +const FLYOUT_TITLE_ID = 'composeDiscoverFlyoutTitle'; + +const YAML_TOGGLE_OPTIONS = [ + { id: 'form', label: 'Form', iconType: 'tableDensityNormal' }, + { id: 'yaml', label: 'YAML', iconType: 'editorCodeBlock' }, +]; + + +export const ComposeDiscoverFlyout: React.FC = ({ + historyKey, + mode = 'create', + initialQuery, + onClose, + services, +}) => { + const [state, dispatch] = useComposeDiscoverState(mode); + + React.useEffect(() => { + if (initialQuery) { + dispatch({ type: 'SET_FULL_QUERY', query: initialQuery }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const isCreate = mode === 'create'; + const title = isCreate ? 'Create alert rule' : 'Edit alert rule'; + + const stepTitles = getStepTitles(state); + const isLastStep = state.step === stepTitles.length - 1; + const tabConfig = getSandboxTabConfig(state); + + return ( + + + {/* Title */} + +

{title}

+
+ + + + {/* Stepper row — standard EuiFlexGroup handles layout alongside the toggle */} + + + {state.yamlMode ? ( + + + YAML MODE + + + ) : ( + ({ + title, + status: i < state.step ? 'complete' : i === state.step ? 'current' : 'incomplete', + }))} + animated + /> + )} + + + + dispatch({ type: 'SET_YAML_MODE', enabled: id === 'yaml' }) + } + buttonSize="compressed" + isIconOnly + data-test-subj="composeDiscoverYamlToggle" + /> + + +
+ + + + + + + {state.yamlMode ? ( + + + dispatch({ type: 'SET_YAML_MODE', enabled: false })} + > + Cancel YAML + + + + + Save + + + + ) : ( + + + Cancel + + + + {state.step > 0 && ( + + dispatch({ type: 'GO_BACK' })} + data-test-subj="composeDiscoverBack" + > + Back + + + )} + + {isLastStep ? ( + + {isCreate ? 'Create rule' : 'Save rule'} + + ) : ( + dispatch({ type: 'GO_NEXT' })} + data-test-subj="composeDiscoverNext" + > + Next + + )} + + + + + )} + + {state.childOpen && ( + dispatch({ type: 'CLOSE_CHILD' })} + /> + )} +
+ ); +}; + diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_form.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_form.tsx new file mode 100644 index 0000000000000..73eb06794692d --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_form.tsx @@ -0,0 +1,796 @@ +/* + * 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, { useCallback, useMemo, type FC } from 'react'; +import { CodeEditor } from '@kbn/code-editor'; +import { ESQL_LANG_ID } from '@kbn/monaco'; +import { + EuiBadge, + EuiButton, + EuiButtonGroup, + EuiCallOut, + EuiComboBox, + EuiFieldNumber, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiHorizontalRule, + EuiPanel, + EuiSelect, + EuiSpacer, + EuiSuperSelect, + EuiSwitch, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import type { ComposeDiscoverState, ComposeDiscoverAction, DelayMode, RecoveryType } from './types'; +import { QuerySummary } from './query_summary'; +import { splitQuery } from './use_heuristic_split'; +import { getStepTitles } from './use_compose_discover_state'; +import type { RuleFormServices } from '../../form/contexts/rule_form_context'; +import { useDataFields } from '../../form/hooks/use_data_fields'; + +interface ComposeDiscoverFormProps { + state: ComposeDiscoverState; + dispatch: React.Dispatch; + services: RuleFormServices; +} + +const SCHEDULE_UNITS = [ + { value: 's', text: 'seconds' }, + { value: 'm', text: 'minutes' }, + { value: 'h', text: 'hours' }, + { value: 'd', text: 'days' }, +]; + +const DELAY_MODE_OPTIONS: Array<{ id: DelayMode; label: string }> = [ + { id: 'immediate', label: 'Immediate' }, + { id: 'breaches', label: 'Consecutive' }, + { id: 'duration', label: 'Duration' }, +]; + +const NO_DATA_OPTIONS = [ + { value: 'no-longer-present', text: 'Alert as "no longer present"' }, + { value: 'do-nothing', text: 'Do nothing' }, + { value: 'keep-last', text: 'Keep last known state' }, +]; + +function parseDurationParts(dur: string): { value: number; unit: string } { + const match = dur.match(/^(\d+)([smhd])$/); + return match ? { value: parseInt(match[1], 10), unit: match[2] } : { value: 1, unit: 'm' }; +} + +// ── Shared sub-components ──────────────────────────────────────────────────── + +interface DelayFieldProps { + label: string; + mode: DelayMode; + value: number; + onModeChange: (mode: DelayMode) => void; + onValueChange: (value: number) => void; + testSubj?: string; +} + +const DelayField: React.FC = ({ + label, + mode, + value, + onModeChange, + onValueChange, + testSubj, +}) => ( + <> + + onModeChange(id as DelayMode)} + isFullWidth + data-test-subj={testSubj} + /> + + {mode !== 'immediate' && ( + + onValueChange(parseInt(e.target.value, 10) || 1)} + /> + + )} + +); + +interface EvalFieldsProps { + state: ComposeDiscoverState; + dispatch: React.Dispatch; +} + +const EvalFields: React.FC = ({ state, dispatch }) => { + const scheduleParts = parseDurationParts(state.schedule); + const lookbackParts = parseDurationParts(state.lookback); + + return ( + <> + + +

Evaluation

+
+ + + + + + + + { + const val = parseInt(e.target.value, 10) || 1; + dispatch({ type: 'SET_SCHEDULE', schedule: `${val}${scheduleParts.unit}` }); + }} + /> + + + + dispatch({ + type: 'SET_SCHEDULE', + schedule: `${scheduleParts.value}${e.target.value}`, + }) + } + /> + + + + + + + + + { + const val = parseInt(e.target.value, 10) || 1; + dispatch({ type: 'SET_LOOKBACK', lookback: `${val}${lookbackParts.unit}` }); + }} + /> + + + + dispatch({ + type: 'SET_LOOKBACK', + lookback: `${lookbackParts.value}${e.target.value}`, + }) + } + /> + + + + + + + + ); +}; + +// ── Recovery type selector (used in both opt1 step 1 and opt2 inline) ──────── + +const RECOVERY_TYPE_OPTIONS = [ + { + value: 'default' as RecoveryType, + inputDisplay: 'Default recovery', + dropdownDisplay: ( + <> + Default recovery + +

Recover automatically when the alert condition is no longer met.

+
+ + ), + }, + { + value: 'no-recovery' as RecoveryType, + inputDisplay: 'No recovery', + disabled: true, + dropdownDisplay: ( + + + + No recovery + Coming soon + + + +

Disable recovery alerts.

+
+
+ ), + }, + { + value: 'custom' as RecoveryType, + inputDisplay: 'Custom recovery', + dropdownDisplay: ( + <> + Custom recovery + +

Define a custom recovery condition.

+
+ + ), + }, +]; + +interface RecoveryTypeSelectorProps { + state: ComposeDiscoverState; + dispatch: React.Dispatch; +} + +const RecoveryTypeSelector: React.FC = ({ state, dispatch }) => ( + + dispatch({ type: 'SET_RECOVERY_TYPE', recoveryType: val })} + fullWidth + hasDividers + data-test-subj="composeDiscoverRecoveryType" + /> + +); + +// ── Step content renderers ──────────────────────────────────────────────────── + +function AlertConditionStep({ + state, + dispatch, + isOpt2, + services, +}: { + state: ComposeDiscoverState; + dispatch: React.Dispatch; + isOpt2: boolean; + services: RuleFormServices; +}) { + // Fetch date fields from the current query for the time field dropdown + const activeQuery = state.queryCommitted + ? state.tracking + ? state.baseQuery + : state.fullQuery + : ''; + const { data: fieldMap } = useDataFields({ + query: activeQuery, + http: services.http, + dataViews: services.dataViews, + }); + const timeFieldOptions = useMemo(() => { + const dateFields = Object.values(fieldMap ?? {}) + .filter((f) => f.type === 'date') + .map((f) => f.name) + .sort(); + if (!dateFields.includes('@timestamp')) dateFields.unshift('@timestamp'); + return dateFields.map((name) => ({ value: name, text: name })); + }, [fieldMap]); + + const handleTrackingToggle = useCallback(() => { + if (state.tracking) { + dispatch({ type: 'DISABLE_TRACKING' }); + } else { + const currentQuery = state.queryCommitted ? state.fullQuery : state.fullQuery; + const { base, alertBlock } = splitQuery(currentQuery); + dispatch({ type: 'ENABLE_TRACKING', base, alertBlock }); + } + }, [state.tracking, state.fullQuery, dispatch]); + + const splitFailed = + state.tracking && + splitQuery([state.baseQuery, state.alertBlock].join('\n')).confidence === 'none' && + !state.baseQuery; + + return ( + <> + +

ES|QL query

+
+ + + {!state.queryCommitted ? ( + <> + + + No query defined yet + + + + dispatch({ type: 'OPEN_CHILD_FOR_STEP', step: state.step })} + data-test-subj="composeDiscoverOpenEditor" + > + Open query editor + + + ) : !state.tracking ? ( + <> + + + dispatch({ type: 'OPEN_CHILD_FOR_STEP', step: state.step })} + data-test-subj="composeDiscoverEditQuery" + > + Edit query + + + ) : ( + <> + {splitFailed && ( + <> + + + + )} + + Base query + + + + + + Alert condition + + + + + dispatch({ type: 'OPEN_CHILD_FOR_STEP', step: state.step })} + data-test-subj="composeDiscoverEditQueries" + > + Edit queries + + + )} + + {/* Time field and group fields sit close to query definitions */} + + + dispatch({ type: 'SET_TIME_FIELD', timeField: e.target.value })} + disabled={state.childOpen} + data-test-subj="composeDiscoverTimeField" + /> + + + + ({ label: f }))} + onChange={(opts) => + dispatch({ type: 'SET_GROUP_FIELDS', fields: opts.map((o) => o.label) }) + } + onCreateOption={(val) => + dispatch({ type: 'SET_GROUP_FIELDS', fields: [...state.groupFields, val] }) + } + placeholder="Add group fields" + data-test-subj="composeDiscoverGroupFields" + /> + + + + + + + {/* In opt2, recovery selector appears inline on this step */} + {isOpt2 && state.tracking && ( + <> + + + + + {state.recoveryType === 'custom' && ( + <> + + + Recovery condition + + + + + dispatch({ type: 'OPEN_CHILD_FOR_STEP', step: state.step })} + > + Edit queries + + + )} + + )} + + {state.tracking && ( + <> + + dispatch({ type: 'SET_ALERT_DELAY_MODE', mode: m })} + onValueChange={(v) => dispatch({ type: 'SET_ALERT_DELAY_VALUE', value: v })} + testSubj="composeDiscoverAlertDelay" + /> + + )} + + + + ); +} + +function RecoveryConditionStep({ + state, + dispatch, +}: { + state: ComposeDiscoverState; + dispatch: React.Dispatch; +}) { + return ( + <> + + + {state.recoveryType === 'custom' && ( + <> + + + + + Base query + + + + + + Recovery condition + + + + + dispatch({ type: 'OPEN_CHILD_FOR_STEP', step: state.step })} + > + Edit queries + + + dispatch({ type: 'SET_RECOVERY_DELAY_MODE', mode: m })} + onValueChange={(v) => dispatch({ type: 'SET_RECOVERY_DELAY_VALUE', value: v })} + testSubj="composeDiscoverRecoveryDelay" + /> + + )} + + ); +} + +function DetailsAndArtifactsStep({ + state, + dispatch, +}: { + state: ComposeDiscoverState; + dispatch: React.Dispatch; +}) { + return ( + <> + +

Rule details

+
+ + + + dispatch({ type: 'SET_NAME', name: e.target.value })} + placeholder="e.g. High CPU hosts" + data-test-subj="composeDiscoverRuleName" + /> + + + + + + ({ label: t }))} + onChange={(opts) => dispatch({ type: 'SET_TAGS', tags: opts.map((o) => o.label) })} + onCreateOption={(val) => + dispatch({ type: 'SET_TAGS', tags: [...state.tags, val] }) + } + placeholder="Add tags" + data-test-subj="composeDiscoverRuleTags" + /> + + + + + +

No data behavior

+
+ + + + + + + + + +

Artifacts

+
+ + + Optional}> + + + + + + Optional} + > + + + + ); +} + +function NotificationsStep({ + state, + dispatch, +}: { + state: ComposeDiscoverState; + dispatch: React.Dispatch; +}) { + return ( + <> + + + +

Notifications

+
+
+ + Lite Policy + +
+ + + + + + + Send a notification when this rule's alerts change status. A linked action policy + will be created with this rule. + + + + Per-episode + + + + + + + dispatch({ type: 'SET_NOTIFICATIONS_ENABLED', enabled: e.target.checked }) + } + data-test-subj="composeDiscoverNotificationsEnabled" + /> + + {state.notificationsEnabled && ( + <> + + + + + + + + + + {}} + /> + + + + + + + Throttle for + + + + + + + + + + + + +

+ You can add matchers, group-by fields, or extra triggers after creating the rule. +

+
+ + )} + + ); +} + +// ── Main form component ─────────────────────────────────────────────────────── + +function buildYaml(state: ComposeDiscoverState): string { + const base = state.baseQuery || ''; + const alertBlock = state.alertBlock || ''; + const recoveryBlock = state.recoveryBlock || ''; + const fullQuery = state.fullQuery || ''; + const indent = (s: string) => s.split('\n').join('\n '); + + const querySection = state.tracking + ? ` query:\n base: |\n ${indent(base)}\n alert_block: |\n ${alertBlock}\n recovery_block: |\n ${recoveryBlock}` + : ` query:\n base: |\n ${indent(fullQuery)}`; + + return `kind: alert +metadata: + name: "${state.name}" + tags: [${state.tags.map((t) => `"${t}"`).join(', ')}] + description: "" +evaluation: +${querySection} + trigger: + condition: "${alertBlock.replace('| WHERE ', '').replace('|WHERE ', '')}" +grouping: + fields: [${state.groupFields.map((f) => `"${f}"`).join(', ')}] +timeField: "${state.timeField}" +schedule: + every: "${state.schedule}" + lookback: "${state.lookback}" +recovery_policy: + type: "${state.recoveryType}" +stateTransition: + alertDelay: { type: "${state.alertDelayMode}" } + recoveryDelay: { type: "${state.recoveryDelayMode}" } +`; +} + +export const ComposeDiscoverForm: React.FC = ({ state, dispatch, services }) => { + const yamlValue = useMemo(() => buildYaml(state), [state]); + + if (state.yamlMode) { + return ( + { + // Two-way sync: update name field as a simple proof-of-concept + // Full YAML→state parsing is out of scope for this prototype + void val; + }} + height={600} + options={{ + minimap: { enabled: false }, + automaticLayout: true, + scrollBeyondLastLine: false, + fontSize: 13, + wordWrap: 'on', + }} + /> + ); + } + + const stepTitles = getStepTitles(state); + const currentStepName = stepTitles[state.step] ?? ''; + + if (currentStepName === 'Alert Condition') { + return ; + } + + if (currentStepName === 'Query Condition') { + return ; + } + + if (currentStepName === 'Recovery Condition') { + return ; + } + + if (currentStepName === 'Details & Artifacts') { + return ; + } + + if (currentStepName === 'Notifications') { + return ; + } + + return null; +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_tabs.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_tabs.tsx new file mode 100644 index 0000000000000..df9245f6a8373 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_tabs.tsx @@ -0,0 +1,230 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiTabs, EuiTab, EuiSpacer, EuiPanel, EuiText } from '@elastic/eui'; +import { CodeEditor } from '@kbn/code-editor'; +import { ESQL_LANG_ID, monaco } from '@kbn/monaco'; +import type { ComposeDiscoverState, ComposeDiscoverAction, QueryTab, SandboxTabConfig } from './types'; +import type { RuleFormServices } from '../../form/contexts/rule_form_context'; +import { useSplitQueryCompletion } from './use_split_query_completion'; + +interface ComposeDiscoverTabsProps { + state: ComposeDiscoverState; + dispatch: React.Dispatch; + /** Controls which tabs are shown. Computed by getSandboxTabConfig() in the parent. */ + tabConfig: SandboxTabConfig; + /** When true, renders only the editor content — tab bar is rendered separately in the header. */ + hideTabBar?: boolean; + /** Required for split-query autocomplete (alert/recovery block editors). */ + services: RuleFormServices; +} + +const lockedEditorStyles: React.CSSProperties = { + opacity: 0.6, + pointerEvents: 'none', + borderBottom: '1px solid var(--euiColorLightShade)', +}; + +interface TabEditorProps { + lockedQuery?: string; + editableQuery: string; + onChange: (value: string) => void; + /** Optional mount callback — used to register per-editor completion providers. */ + onEditorMount?: (editor: monaco.editor.IStandaloneCodeEditor) => void; +} + +const TabEditor: React.FC = ({ lockedQuery, editableQuery, onChange, onEditorMount }) => { + const lockedLineCount = lockedQuery ? lockedQuery.split('\n').length : 0; + const lockedHeight = lockedLineCount * 19 + 8; + + const editableOptions = useMemo( + () => ({ + minimap: { enabled: false }, + automaticLayout: true, + scrollBeyondLastLine: false, + fontSize: 13, + ...(lockedLineCount > 0 && { + lineNumbers: ((n: number) => String(n + lockedLineCount)) as unknown as 'on', + }), + }), + [lockedLineCount] + ); + + if (!lockedQuery) { + return ( + + ); + } + + return ( + <> +
+ +
+ + + ); +}; + +const ALL_TABS: Array<{ id: QueryTab; name: string }> = [ + { id: 'base', name: 'Base query' }, + { id: 'alert', name: 'Alert query' }, + { id: 'recovery', name: 'Recovery query' }, +]; + +/** Maps a SandboxTabConfig to the set of tab IDs to render. */ +function getVisibleTabIds(tabConfig: SandboxTabConfig): QueryTab[] { + switch (tabConfig.type) { + case 'base-alert': + return ['base', 'alert']; + case 'base-recovery': + // Only show recovery tab — base is shown as locked context above the editor, not a separate tab + return ['recovery']; + case 'all-three': + return ['base', 'alert', 'recovery']; + case 'single': + default: + return []; + } +} + +/** True when the recovery tab should be shown but treated as disabled. */ +function isRecoveryTabDisabled(state: ComposeDiscoverState, tabConfig: SandboxTabConfig): boolean { + if (tabConfig.type !== 'all-three') return false; + return state.recoveryType !== 'custom'; +} + +export const ComposeDiscoverTabs: React.FC = ({ + state, + dispatch, + tabConfig, + hideTabBar = false, + services, +}) => { + const visibleTabIds = getVisibleTabIds(tabConfig); + const recoveryDisabled = isRecoveryTabDisabled(state, tabConfig); + const visibleTabs = ALL_TABS.filter((t) => visibleTabIds.includes(t.id)); + + // Split-query autocomplete: alert and recovery block editors need the base query + // as context so they can resolve column names from STATS, EVAL, etc. + const { onEditorMount: onAlertEditorMount } = useSplitQueryCompletion({ + baseQuery: state.baseQuery, + search: services.data.search.search, + }); + const { onEditorMount: onRecoveryEditorMount } = useSplitQueryCompletion({ + baseQuery: state.baseQuery, + search: services.data.search.search, + }); + + const renderEditor = () => { + switch (state.activeTab) { + case 'base': + return ( + dispatch({ type: 'SET_BASE_QUERY', query: val })} + /> + ); + case 'alert': + return ( + dispatch({ type: 'SET_ALERT_BLOCK', block: val })} + onEditorMount={onAlertEditorMount} + /> + ); + case 'recovery': + if (recoveryDisabled) { + return ( + + + Enable custom recovery in the rule form to edit a recovery condition. + + + ); + } + return ( + dispatch({ type: 'SET_RECOVERY_BLOCK', block: val })} + onEditorMount={onRecoveryEditorMount} + /> + ); + default: + return null; + } + }; + + // Ensure activeTab is valid for the current visible set; default to first visible tab + const activeTab = visibleTabIds.includes(state.activeTab) + ? state.activeTab + : visibleTabIds[0] ?? 'alert'; + + // Sync activeTab into state if it drifted (e.g. tabConfig changed) + React.useEffect(() => { + if (activeTab !== state.activeTab) { + dispatch({ type: 'SET_TAB', tab: activeTab }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab]); + + return ( + <> + {!hideTabBar && ( + + {visibleTabs.map((tab) => ( + dispatch({ type: 'SET_TAB', tab: tab.id })} + disabled={tab.id === 'recovery' && recoveryDisabled} + data-test-subj={`composeDiscoverTab-${tab.id}`} + > + {tab.name} + + ))} + + )} + {!hideTabBar && } + {renderEditor()} + + ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/horizontal_minimal_stepper.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/horizontal_minimal_stepper.tsx new file mode 100644 index 0000000000000..bf3c247b37c00 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/horizontal_minimal_stepper.tsx @@ -0,0 +1,115 @@ +/* + * 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 { useEuiTheme, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; + +/** Mirrors the status subset used by EuiStepsHorizontal. */ +export type MinimalStepStatus = 'current' | 'complete' | 'incomplete'; + +export interface MinimalStep { + title: string; + status: MinimalStepStatus; +} + +export interface HorizontalMinimalStepperProps { + /** Steps with their current status — same shape as EuiStepsHorizontal steps (subset). */ + steps: MinimalStep[]; + /** + * When true, indicators animate on step change using a spring curve. + * Defaults to true. Pass false to disable for testing or reduced-motion contexts. + */ + animated?: boolean; +} + +/** + * Minimal horizontal stepper for compact flyout headers. + * + * Renders a row of small indicators (dots + pill for current step), a bold + * current-step title, and a muted N / N counter. + * + * Layout is intentionally self-contained — place alongside other elements + * using standard EuiFlexGroup/EuiFlexItem outside this component: + * + * + * + * + * + * + * + * + * + */ +export const HorizontalMinimalStepper: React.FC = ({ + steps, + animated = true, +}) => { + const { euiTheme } = useEuiTheme(); + + const DOT_SIZE = 8; + const BAR_WIDTH = 24; + const BAR_HEIGHT = DOT_SIZE; + const GAP = 4; + + const activeColor = euiTheme.colors.primary; + const futureColor = euiTheme.colors.lightShade; + + const currentIndex = steps.findIndex((s) => s.status === 'current'); + const currentTitle = currentIndex >= 0 ? steps[currentIndex].title : ''; + const total = steps.length; + + const transition = animated + ? 'width 220ms cubic-bezier(0.34, 1.56, 0.64, 1), ' + + 'border-radius 220ms cubic-bezier(0.34, 1.56, 0.64, 1), ' + + 'background-color 150ms ease' + : undefined; + + return ( + + {/* Step indicators */} + +
+ {steps.map((step, i) => { + const isCurrent = step.status === 'current'; + const isComplete = step.status === 'complete'; + + return ( +
+ ); + })} +
+ + + {/* Current step title */} + + + {currentTitle} + + + + {/* Spacer */} + + + {/* N / N counter */} + + + {currentIndex + 1} / {total} + + + + ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/index.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/index.ts new file mode 100644 index 0000000000000..d92095b65644c --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ComposeDiscoverFlyout } from './compose_discover_flyout'; +export type { ComposeDiscoverFlyoutProps } from './compose_discover_flyout'; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/query_summary.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/query_summary.tsx new file mode 100644 index 0000000000000..db9baf5409e81 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/query_summary.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiPanel, EuiText } from '@elastic/eui'; +import { CodeEditor } from '@kbn/code-editor'; +import { ESQL_LANG_ID } from '@kbn/monaco'; + +interface QuerySummaryProps { + query: string; + label?: string; + maxLines?: number; +} + +export const QuerySummary: React.FC = ({ query, label, maxLines = 5 }) => { + const lineCount = Math.min(query.split('\n').length, maxLines); + const height = lineCount * 19 + 16; + + if (!query.trim()) { + return ( + + + {label ? `No ${label.toLowerCase()} defined` : 'No query defined'} + + + ); + } + + return ( + + + + ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/types.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/types.ts new file mode 100644 index 0000000000000..c4716c03dbcb9 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/types.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type ComposeDiscoverMode = 'create' | 'edit'; +// 'no-recovery' is a prototype-only placeholder — not yet wired to the API +export type RecoveryType = 'default' | 'no-recovery' | 'custom'; +export type QueryTab = 'base' | 'alert' | 'recovery'; +export type DelayMode = 'immediate' | 'breaches' | 'duration'; +// PROTOTYPE: opt1 = separated alert/recovery steps; opt2 = combined query step +export type LayoutOption = 'opt1' | 'opt2'; + +/** + * Describes which tabs the Discover Sandbox should show. + * Computed from state by getSandboxTabConfig(). + */ +export type SandboxTabConfig = + | { type: 'single' } // no tracking, no tabs — single editor + | { type: 'base-alert' } // opt1 alert step, tracking on + | { type: 'base-recovery' } // opt1 recovery step, custom recovery selected + | { type: 'all-three' }; // opt2, yaml mode, or any all-queries view + +export interface ComposeDiscoverState { + mode: ComposeDiscoverMode; + // PROTOTYPE: layout option toggle + option: LayoutOption; + step: number; + tracking: boolean; + fullQuery: string; + baseQuery: string; + alertBlock: string; + recoveryBlock: string; + recoveryType: RecoveryType; + notificationsEnabled: boolean; + name: string; + tags: string[]; + schedule: string; + lookback: string; + timeField: string; + groupFields: string[]; + alertDelayMode: DelayMode; + alertDelayValue: number; + recoveryDelayMode: DelayMode; + recoveryDelayValue: number; + activeTab: QueryTab; + yamlMode: boolean; + childOpen: boolean; + queryCommitted: boolean; +} + +export type ComposeDiscoverAction = + | { type: 'SET_NAME'; name: string } + | { type: 'SET_TAGS'; tags: string[] } + | { type: 'SET_FULL_QUERY'; query: string } + | { type: 'SET_BASE_QUERY'; query: string } + | { type: 'SET_ALERT_BLOCK'; block: string } + | { type: 'SET_RECOVERY_BLOCK'; block: string } + | { type: 'SET_RECOVERY_TYPE'; recoveryType: RecoveryType } + | { type: 'ENABLE_TRACKING'; base: string; alertBlock: string } + | { type: 'DISABLE_TRACKING' } + | { type: 'SET_TAB'; tab: QueryTab } + | { type: 'SET_SCHEDULE'; schedule: string } + | { type: 'SET_LOOKBACK'; lookback: string } + | { type: 'SET_TIME_FIELD'; timeField: string } + | { type: 'SET_GROUP_FIELDS'; fields: string[] } + | { type: 'SET_ALERT_DELAY_MODE'; mode: DelayMode } + | { type: 'SET_ALERT_DELAY_VALUE'; value: number } + | { type: 'SET_RECOVERY_DELAY_MODE'; mode: DelayMode } + | { type: 'SET_RECOVERY_DELAY_VALUE'; value: number } + | { type: 'SET_YAML_MODE'; enabled: boolean } + | { type: 'SET_OPTION'; option: LayoutOption } + | { type: 'SET_STEP'; step: number } + | { type: 'GO_NEXT' } + | { type: 'GO_BACK' } + | { type: 'SET_NOTIFICATIONS_ENABLED'; enabled: boolean } + | { type: 'OPEN_CHILD' } + | { type: 'OPEN_CHILD_FOR_STEP'; step: number } + | { type: 'CLOSE_CHILD' } + | { type: 'COMMIT_CHILD_QUERY'; fullQuery: string } + | { + type: 'COMMIT_CHILD_SPLIT'; + baseQuery: string; + alertBlock: string; + recoveryBlock: string; + }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_compose_discover_state.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_compose_discover_state.ts new file mode 100644 index 0000000000000..79f9cb47647dc --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_compose_discover_state.ts @@ -0,0 +1,231 @@ +/* + * 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 { useReducer } from 'react'; +import type { + ComposeDiscoverState, + ComposeDiscoverAction, + ComposeDiscoverMode, + SandboxTabConfig, +} from './types'; +import { guessRecoveryBlock } from './use_heuristic_split'; + +const SAMPLE_QUERY = `FROM logs-* +| STATS count = COUNT(*) BY host.name +| WHERE count > 0`; + +const createInitialState = (mode: ComposeDiscoverMode): ComposeDiscoverState => ({ + mode, + option: 'opt1', + step: 0, + tracking: false, + fullQuery: mode === 'create' ? SAMPLE_QUERY : '', + baseQuery: '', + alertBlock: '', + recoveryBlock: '', + recoveryType: 'default', + notificationsEnabled: false, + name: '', + tags: [], + schedule: '1m', + lookback: '5m', + timeField: '@timestamp', + groupFields: [], + alertDelayMode: 'immediate', + alertDelayValue: 1, + recoveryDelayMode: 'immediate', + recoveryDelayValue: 1, + activeTab: 'alert', + yamlMode: false, + childOpen: mode === 'create', + queryCommitted: mode === 'edit', +}); + +/** + * Returns the ordered list of step titles for the current state. + * Used for the stepper display and step routing in the form. + */ +export function getStepTitles(state: Pick): string[] { + if (state.option === 'opt2') return ['Query Condition', 'Details & Artifacts', 'Notifications']; + if (state.tracking) + return ['Alert Condition', 'Recovery Condition', 'Details & Artifacts', 'Notifications']; + return ['Alert Condition', 'Details & Artifacts', 'Notifications']; +} + +/** + * Returns the SandboxTabConfig for the current state — determines which tabs + * the Discover Sandbox child flyout should display. + */ +export function getSandboxTabConfig(state: ComposeDiscoverState): SandboxTabConfig { + if (state.yamlMode) return { type: 'all-three' }; + // opt2: single editor until query is committed, then full 3-tab view + if (state.option === 'opt2') { + return state.queryCommitted ? { type: 'all-three' } : { type: 'single' }; + } + + // opt1: tab config depends on the current step + const stepTitles = getStepTitles(state); + const currentStepName = stepTitles[state.step] ?? ''; + + if (currentStepName === 'Recovery Condition' && state.recoveryType === 'custom') { + return { type: 'base-recovery' }; + } + if ( + (currentStepName === 'Alert Condition' || currentStepName === 'Query Condition') && + state.tracking + ) { + return { type: 'base-alert' }; + } + return { type: 'single' }; +} + +function getDefaultTabForStep( + state: ComposeDiscoverState, + tabConfig: SandboxTabConfig +): ComposeDiscoverState['activeTab'] { + if (tabConfig.type === 'base-recovery') return 'recovery'; + if (tabConfig.type === 'base-alert') return 'alert'; + return 'alert'; +} + +function reducer( + state: ComposeDiscoverState, + action: ComposeDiscoverAction +): ComposeDiscoverState { + switch (action.type) { + case 'SET_NAME': + return { ...state, name: action.name }; + case 'SET_TAGS': + return { ...state, tags: action.tags }; + case 'SET_FULL_QUERY': + return { ...state, fullQuery: action.query }; + case 'SET_BASE_QUERY': + return { ...state, baseQuery: action.query }; + case 'SET_ALERT_BLOCK': + return { ...state, alertBlock: action.block }; + case 'SET_RECOVERY_BLOCK': + return { ...state, recoveryBlock: action.block }; + case 'SET_RECOVERY_TYPE': { + const newBlock = + action.recoveryType === 'custom' && !state.recoveryBlock && state.alertBlock + ? guessRecoveryBlock(state.alertBlock) + : state.recoveryBlock; + return { + ...state, + recoveryType: action.recoveryType, + recoveryBlock: newBlock, + // Open the sandbox on the recovery tab when custom recovery is selected + ...(action.recoveryType === 'custom' + ? { childOpen: true, activeTab: 'recovery' as const } + : {}), + }; + } + case 'ENABLE_TRACKING': { + // If currently on recovery step, jump back to step 0 + const steps = getStepTitles({ option: state.option, tracking: true }); + const clampedStep = state.step < steps.length ? state.step : 0; + return { + ...state, + tracking: true, + baseQuery: action.base, + alertBlock: action.alertBlock, + activeTab: 'alert', + step: clampedStep, + }; + } + case 'DISABLE_TRACKING': + return { + ...state, + tracking: false, + step: 0, + fullQuery: [state.baseQuery, state.alertBlock].filter(Boolean).join('\n'), + baseQuery: '', + alertBlock: '', + recoveryBlock: '', + recoveryType: 'default', + }; + case 'SET_TAB': + return { ...state, activeTab: action.tab }; + case 'SET_SCHEDULE': + return { ...state, schedule: action.schedule }; + case 'SET_LOOKBACK': + return { ...state, lookback: action.lookback }; + case 'SET_TIME_FIELD': + return { ...state, timeField: action.timeField }; + case 'SET_GROUP_FIELDS': + return { ...state, groupFields: action.fields }; + case 'SET_ALERT_DELAY_MODE': + return { ...state, alertDelayMode: action.mode }; + case 'SET_ALERT_DELAY_VALUE': + return { ...state, alertDelayValue: action.value }; + case 'SET_RECOVERY_DELAY_MODE': + return { ...state, recoveryDelayMode: action.mode }; + case 'SET_RECOVERY_DELAY_VALUE': + return { ...state, recoveryDelayValue: action.value }; + case 'SET_YAML_MODE': + return { + ...state, + yamlMode: action.enabled, + // YAML mode force-opens the Sandbox with all 3 tabs + childOpen: action.enabled ? true : state.childOpen, + activeTab: action.enabled ? 'base' : state.activeTab, + }; + case 'SET_NOTIFICATIONS_ENABLED': + return { ...state, notificationsEnabled: action.enabled }; + case 'SET_OPTION': + return { ...state, option: action.option, step: 0 }; + case 'SET_STEP': + return { ...state, step: action.step }; + case 'GO_NEXT': { + const steps = getStepTitles(state); + const nextStep = Math.min(state.step + 1, steps.length - 1); + return { ...state, step: nextStep, childOpen: false }; + } + case 'GO_BACK': { + const prevStep = Math.max(state.step - 1, 0); + return { ...state, step: prevStep, childOpen: false }; + } + case 'OPEN_CHILD': { + const tabConfig = getSandboxTabConfig(state); + return { + ...state, + childOpen: true, + activeTab: getDefaultTabForStep(state, tabConfig), + }; + } + case 'OPEN_CHILD_FOR_STEP': { + // Use provided step to compute the correct default tab + const stateAtStep = { ...state, step: action.step }; + const tabConfig = getSandboxTabConfig(stateAtStep); + return { + ...state, + step: action.step, + childOpen: true, + activeTab: getDefaultTabForStep(stateAtStep, tabConfig), + }; + } + case 'CLOSE_CHILD': + return { ...state, childOpen: false }; + case 'COMMIT_CHILD_QUERY': + return { ...state, fullQuery: action.fullQuery, childOpen: false, queryCommitted: true }; + case 'COMMIT_CHILD_SPLIT': + return { + ...state, + baseQuery: action.baseQuery, + alertBlock: action.alertBlock, + recoveryBlock: action.recoveryBlock, + childOpen: false, + queryCommitted: true, + }; + default: + return state; + } +} + +export const useComposeDiscoverState = (mode: ComposeDiscoverMode = 'create') => { + return useReducer(reducer, mode, createInitialState); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_esql_providers.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_esql_providers.ts new file mode 100644 index 0000000000000..c6ab8c313656c --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_esql_providers.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect } from 'react'; +import { ESQLLang, ESQL_LANG_ID, monaco } from '@kbn/monaco'; +import type { ESQLCallbacks } from '@kbn/esql-types'; +import { useEsqlCallbacks } from '../../form/hooks/use_esql_callbacks'; +import type { RuleFormServices } from '../../form/contexts/rule_form_context'; + +let registeredDisposables: monaco.IDisposable[] | null = null; + +function registerProviders(callbacks: ESQLCallbacks) { + if (registeredDisposables) return; + const disposables: monaco.IDisposable[] = []; + + const suggestion = ESQLLang.getSuggestionProvider?.(callbacks); + if (suggestion) { + disposables.push( + monaco.languages.registerCompletionItemProvider(ESQL_LANG_ID, suggestion) + ); + } + + const signature = ESQLLang.getSignatureProvider?.(callbacks); + if (signature) { + disposables.push( + monaco.languages.registerSignatureHelpProvider(ESQL_LANG_ID, signature) + ); + } + + const hover = ESQLLang.getHoverProvider?.(callbacks); + if (hover) { + disposables.push(monaco.languages.registerHoverProvider(ESQL_LANG_ID, hover)); + } + + registeredDisposables = disposables; +} + +export const useEsqlAutocomplete = (services: RuleFormServices) => { + const callbacks = useEsqlCallbacks({ + application: services.application, + http: services.http, + search: services.data.search.search, + }); + + useEffect(() => { + registerProviders(callbacks); + return () => { + if (registeredDisposables) { + registeredDisposables.forEach((d) => d.dispose()); + registeredDisposables = null; + } + }; + }, [callbacks]); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_heuristic_split.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_heuristic_split.ts new file mode 100644 index 0000000000000..3d245c60f9da0 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_heuristic_split.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; + +export interface SplitResult { + base: string; + alertBlock: string; + confidence: 'high' | 'low' | 'none'; +} + +/** + * Splits an ES|QL query into base and alert block using pipe-segment analysis. + * + * Strategy: find the last STATS segment and the first WHERE after it. + * Everything up to and including STATS is the base query. Everything from + * the first WHERE onward is the alert block. + * + * Handles both multi-line and single-line queries by operating on pipe + * segments rather than lines. + */ +export function splitQuery(query: string): SplitResult { + if (!query.trim()) { + return { base: '', alertBlock: '', confidence: 'none' }; + } + + const segments: Array<{ text: string; start: number; end: number; keyword: string }> = []; + let current = ''; + let segStart = 0; + + for (let i = 0; i <= query.length; i++) { + if (i === query.length || query[i] === '|') { + if (current.trim()) { + const trimmed = current.trim(); + const keyword = trimmed.split(/\s+/)[0].toUpperCase(); + segments.push({ text: current, start: segStart, end: i, keyword }); + } + current = ''; + segStart = i + 1; + } else { + current += query[i]; + } + } + + if (segments.length === 0) { + return { base: query, alertBlock: '', confidence: 'none' }; + } + + let lastStatsIdx = -1; + for (let i = segments.length - 1; i >= 0; i--) { + if (segments[i].keyword === 'STATS') { + lastStatsIdx = i; + break; + } + } + + if (lastStatsIdx === -1) { + return { base: '', alertBlock: query.trim(), confidence: 'none' }; + } + + let firstWhereAfterStats = -1; + for (let i = lastStatsIdx + 1; i < segments.length; i++) { + if (segments[i].keyword === 'WHERE') { + firstWhereAfterStats = i; + break; + } + } + + if (firstWhereAfterStats === -1) { + return { base: query.trim(), alertBlock: '', confidence: 'low' }; + } + + // segments[].start is the position AFTER the preceding |, so back up to + // include the pipe itself in the alert block. + const afterPipe = segments[firstWhereAfterStats].start; + const pipePos = query.lastIndexOf('|', afterPipe); + const splitPos = pipePos >= 0 ? pipePos : afterPipe; + + const base = query.slice(0, splitPos).trim(); + const alertBlock = query.slice(splitPos).trim(); + + return { base, alertBlock, confidence: 'high' }; +} + +export function guessRecoveryBlock(alertBlock: string): string { + return alertBlock + .replace(/>=/, '⟨LE⟩') + .replace(/<=/, '⟨GE⟩') + .replace(/>/, '⟨LT⟩') + .replace(/=') + .replace(/⟨LT⟩/, '<') + .replace(/⟨GT⟩/, '>'); +} + +export const useHeuristicSplit = (fullQuery: string): SplitResult => { + return useMemo(() => splitQuery(fullQuery), [fullQuery]); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_query_execution.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_query_execution.ts new file mode 100644 index 0000000000000..fd5349db24624 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_query_execution.ts @@ -0,0 +1,173 @@ +/* + * 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 { useCallback, useMemo, useRef, useState } from 'react'; +import { useQuery, useQueryClient } from '@kbn/react-query'; +import { getESQLResults } from '@kbn/esql-utils'; +import type { EuiDataGridColumn } from '@elastic/eui'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; + +const MAX_ROWS = 500; + +export interface QueryColumn extends EuiDataGridColumn { + esType: string; +} + +export interface QueryExecutionResult { + columns: QueryColumn[]; + rows: Array>; + totalRowCount: number; + isLoading: boolean; + isError: boolean; + error: string | null; + run: () => void; + hasRun: boolean; + /** The query that was last explicitly executed — use this for the chart to avoid auto-refresh on keystrokes. */ + lastExecutedQuery: string | null; +} + +interface TimeRange { + from: string; + to: string; +} + +interface UseQueryExecutionParams { + query: string; + timeField: string; + timeRange: TimeRange; + data: DataPublicPluginStart; +} + +function formatCellValue(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +function injectTimeFilter(query: string, timeField: string): string { + const trimmed = query.trim(); + if (!trimmed) return trimmed; + + const lines = trimmed.split('\n'); + const fromIdx = lines.findIndex((line) => /^\s*FROM\s/i.test(line)); + if (fromIdx === -1) return trimmed; + + const whereClause = `| WHERE ${timeField} >= ?_tstart AND ${timeField} <= ?_tend`; + lines.splice(fromIdx + 1, 0, whereClause); + return lines.join('\n'); +} + +export const useQueryExecution = ({ + query, + timeField, + timeRange, + data, +}: UseQueryExecutionParams): QueryExecutionResult => { + const queryClient = useQueryClient(); + const [executionQuery, setExecutionQuery] = useState(null); + const [executionTimeRange, setExecutionTimeRange] = useState(null); + const [executionTimeField, setExecutionTimeField] = useState(null); + + const canExecute = Boolean(executionQuery?.trim() && executionTimeField?.trim()); + + const fetchResults = useCallback(async () => { + if (!executionQuery || !executionTimeRange || !executionTimeField) { + return { columns: [], values: [] }; + } + + const queryWithTime = injectTimeFilter(executionQuery, executionTimeField); + + // eslint-disable-next-line no-console + console.debug('[useQueryExecution] executing:', queryWithTime); + + const result = await getESQLResults({ + esqlQuery: queryWithTime, + search: data.search.search, + dropNullColumns: true, + timeRange: executionTimeRange, + }); + + return result.response; + }, [executionQuery, executionTimeRange, executionTimeField, data.search.search]); + + const { + data: response, + isLoading, + isError, + error, + } = useQuery({ + queryKey: ['composeDiscoverQuery', executionQuery, executionTimeRange, executionTimeField], + queryFn: fetchResults, + enabled: canExecute, + keepPreviousData: true, + refetchOnWindowFocus: false, + retry: false, + }); + + // Refs ensure run() always reads the latest param values, avoiding stale + // closures when the user switches tabs and immediately clicks Run. + const paramsRef = useRef({ query, timeField, timeRange }); + paramsRef.current = { query, timeField, timeRange }; + + const execRef = useRef({ query: executionQuery, timeRange: executionTimeRange, timeField: executionTimeField }); + execRef.current = { query: executionQuery, timeRange: executionTimeRange, timeField: executionTimeField }; + + const run = useCallback(() => { + const { query: q, timeField: tf, timeRange: tr } = paramsRef.current; + const exec = execRef.current; + const trimmed = q.trim(); + if (!trimmed) return; + + // eslint-disable-next-line no-console + console.debug('[useQueryExecution] run() query:', trimmed); + + const rangeChanged = exec.timeRange?.from !== tr.from || exec.timeRange?.to !== tr.to; + const fieldChanged = exec.timeField !== tf; + if (trimmed === exec.query && !rangeChanged && !fieldChanged) { + queryClient.invalidateQueries({ queryKey: ['composeDiscoverQuery'] }); + } else { + setExecutionQuery(trimmed); + setExecutionTimeRange({ ...tr }); + setExecutionTimeField(tf); + } + }, [queryClient]); + + const columns: QueryColumn[] = useMemo( + () => + (response?.columns ?? []).map((col) => ({ + id: col.name, + displayAsText: col.name, + esType: col.type, + })), + [response?.columns] + ); + + const { rows, totalRowCount } = useMemo(() => { + const allRows = (response?.values ?? []).map((row) => { + const record: Record = {}; + (response?.columns ?? []).forEach((col, idx) => { + record[col.name] = formatCellValue(row[idx]); + }); + return record; + }); + return { rows: allRows.slice(0, MAX_ROWS), totalRowCount: allRows.length }; + }, [response?.columns, response?.values]); + + const errorMessage = isError && error instanceof Error ? error.message : null; + + return { + columns, + rows, + totalRowCount, + isLoading: canExecute && isLoading, + isError, + error: errorMessage, + run, + hasRun: executionQuery !== null, + lastExecutedQuery: executionQuery, + }; +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_split_query_completion.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_split_query_completion.ts new file mode 100644 index 0000000000000..4856fb147b96e --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_split_query_completion.ts @@ -0,0 +1,115 @@ +/* + * 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 { useCallback, useEffect, useRef } from 'react'; +import { suggest } from '@kbn/esql-language'; +import { getEsqlColumns } from '@kbn/esql-utils'; +import { ESQL_LANG_ID, monaco } from '@kbn/monaco'; +import type { ESQLCallbacks } from '@kbn/esql-types'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; + +interface UseSplitQueryCompletionParams { + /** + * The base query whose output columns provide the autocomplete context. + * e.g. "FROM logs-* | STATS cpu = AVG(cpu) BY host.name" + * The block editor shows only the appended fragment (e.g. "| WHERE cpu > 0.8"), + * but autocomplete needs to see both. + */ + baseQuery: string; + search: DataPublicPluginStart['search']['search']; +} + +/** + * Registers a per-editor Monaco completion provider that understands split ES|QL queries. + * + * Technique: at suggestion time, prepend `baseQuery + ' '` to the editor's text to form + * a syntactically complete ES|QL query, adjust the cursor offset, and call `suggest()`. + * This makes column names from the base query available for autocomplete in the block editor. + * + * Usage: + * const { onEditorMount } = useSplitQueryCompletion({ baseQuery, search }); + * + * + * Swap strategy: replace this hook with a different implementation if ES|QL ever adds a + * native "query context" parameter to its autocomplete API. + */ +export function useSplitQueryCompletion({ baseQuery, search }: UseSplitQueryCompletionParams) { + // Refs so the provider closure always reads current values without re-registering. + const baseQueryRef = useRef(baseQuery); + const searchRef = useRef(search); + useEffect(() => { baseQueryRef.current = baseQuery; }); + useEffect(() => { searchRef.current = search; }); + + // Store the disposable for cleanup on unmount. + const disposableRef = useRef(null); + + const onEditorMount = useCallback((editor: monaco.editor.IStandaloneCodeEditor) => { + const callbacks: ESQLCallbacks = { + getColumnsFor: async ({ query } = {}) => + getEsqlColumns({ esqlQuery: query ?? '', search: searchRef.current }), + }; + + disposableRef.current = monaco.languages.registerCompletionItemProvider(ESQL_LANG_ID, { + triggerCharacters: [' ', ',', '.', '|', '('], + + async provideCompletionItems(model, position) { + // Guard: only handle the specific editor this hook was mounted on. + if (model.id !== editor.getModel()?.id) return { suggestions: [] }; + + const currentBaseQuery = baseQueryRef.current; + if (!currentBaseQuery.trim()) return { suggestions: [] }; + + const editorText = model.getValue(); + const fullQuery = `${currentBaseQuery} ${editorText}`; + const editorOffset = model.getOffsetAt(position); + // Offset into the full query = base length + 1 (for the space) + editor offset + const fullQueryOffset = currentBaseQuery.length + 1 + editorOffset; + + const rawSuggestions = await suggest(fullQuery, fullQueryOffset, callbacks); + + // Use word range at cursor — simpler than full range computation and works well + // for field names, functions, and keywords which are the main autocomplete targets. + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + return { + suggestions: rawSuggestions.map((s) => ({ + label: s.label, + insertText: s.text, + insertTextRules: s.asSnippet + ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + : undefined, + kind: Object.prototype.hasOwnProperty.call(monaco.languages.CompletionItemKind, s.kind) + ? monaco.languages.CompletionItemKind[ + s.kind as keyof typeof monaco.languages.CompletionItemKind + ] + : monaco.languages.CompletionItemKind.Field, + detail: s.detail, + sortText: s.sortText, + filterText: s.filterText, + range, + })), + }; + }, + }); + }, []); // stable — reads baseQuery and search via refs + + // Clean up the provider when the component using this hook unmounts. + useEffect(() => { + return () => { + disposableRef.current?.dispose(); + disposableRef.current = null; + }; + }, []); + + return { onEditorMount }; +} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/index.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/index.tsx index 0bffa8a7b557d..83cc716446eed 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/index.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/index.tsx @@ -10,6 +10,7 @@ import { EuiLoadingSpinner } from '@elastic/eui'; import type { RuleFormFlyoutProps } from './rule_form_flyout'; import type { DynamicRuleFormFlyoutProps } from './dynamic_rule_form_flyout'; import type { StandaloneRuleFormFlyoutProps } from './standalone_rule_form_flyout'; +import type { ComposeDiscoverFlyoutProps } from './compose_discover'; // Lazy load flyout components const LazyRuleFormFlyout = React.lazy(() => @@ -30,8 +31,19 @@ const LazyStandaloneRuleFormFlyout = React.lazy(() => })) ); +const LazyComposeDiscoverFlyout = React.lazy(() => + import('./compose_discover').then((module) => ({ + default: module.ComposeDiscoverFlyout, + })) +); + // Export lazy components directly for consumers who need full control over Suspense -export { LazyDynamicRuleFormFlyout, LazyStandaloneRuleFormFlyout, LazyRuleFormFlyout }; +export { + LazyDynamicRuleFormFlyout, + LazyStandaloneRuleFormFlyout, + LazyRuleFormFlyout, + LazyComposeDiscoverFlyout, +}; /** Base flyout wrapper - use with DynamicRuleForm or StandaloneRuleForm as children */ export const RuleFormFlyout = (props: RuleFormFlyoutProps) => ( @@ -54,8 +66,16 @@ export const StandaloneRuleFormFlyout = (props: StandaloneRuleFormFlyoutProps) = ); +/** Compose Discover flyout — two-layer flyout with compact rule form + Discover child */ +export const ComposeDiscoverFlyout = (props: ComposeDiscoverFlyoutProps) => ( + }> + + +); + // Export types export type { RuleFormFlyoutProps } from './rule_form_flyout'; export type { DynamicRuleFormFlyoutProps } from './dynamic_rule_form_flyout'; export type { StandaloneRuleFormFlyoutProps } from './standalone_rule_form_flyout'; +export type { ComposeDiscoverFlyoutProps } from './compose_discover'; export type * from '../form/types'; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/index.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/index.ts index ed898d38b490a..a3fc864cefeed 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/index.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/index.ts @@ -6,13 +6,14 @@ */ // Pre-composed flyouts (lazy loaded) - recommended for most use cases -export { DynamicRuleFormFlyout, StandaloneRuleFormFlyout } from './flyout'; +export { DynamicRuleFormFlyout, StandaloneRuleFormFlyout, ComposeDiscoverFlyout } from './flyout'; // Lazy components (without Suspense wrapper) - for consumers who need full control export { LazyDynamicRuleFormFlyout, LazyStandaloneRuleFormFlyout, LazyRuleFormFlyout, + LazyComposeDiscoverFlyout, } from './flyout'; // Constants @@ -53,4 +54,5 @@ export type { RuleFormFlyoutProps, DynamicRuleFormFlyoutProps, StandaloneRuleFormFlyoutProps, + ComposeDiscoverFlyoutProps, } from './flyout'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.tsx index 305471b9c156f..34ca4bd11cb46 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.tsx @@ -18,15 +18,20 @@ import { type Criteria, } from '@elastic/eui'; import { CoreStart, useService } from '@kbn/core-di-browser'; +import { PluginStart } from '@kbn/core-di'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { useDebouncedValue } from '@kbn/react-hooks'; import type { FindRulesSortField } from '@kbn/alerting-v2-schemas'; +import { ComposeDiscoverFlyout } from '@kbn/alerting-v2-rule-form'; import type { RuleApiResponse } from '../../services/rules_api'; import { useFetchRules } from '../../hooks/use_fetch_rules'; import { useFetchRuleTags } from '../../hooks/use_fetch_rule_tags'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; -import { paths } from '../../constants'; + import { RulesListTableContainer } from './rules_list_table_container'; import type { RulesListTableSortField } from './rules_list_table'; import { ModeFilterPopover } from '../../components/rule/popovers/mode_filter_popover'; @@ -48,10 +53,21 @@ const TABLE_FIELD_TO_API_SORT_FIELD = Object.fromEntries( ) as Partial>; export const RulesListPage = () => { - const { basePath } = useService(CoreStart('http')); - + const http = useService(CoreStart('http')); + const notifications = useService(CoreStart('notifications')); + const application = useService(CoreStart('application')); + const data = useService(PluginStart('data')) as DataPublicPluginStart; + const dataViews = useService(PluginStart('dataViews')) as DataViewsPublicPluginStart; + const lens = useService(PluginStart('lens')) as LensPublicStart; useBreadcrumbs('rules_list'); + const [flyoutOpen, setFlyoutOpen] = useState(false); + const historyKey = useMemo(() => Symbol('ruleAuthoring'), []); + const ruleFormServices = useMemo( + () => ({ http, data, dataViews, notifications, application, lens }), + [http, data, dataViews, notifications, application, lens] + ); + const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE); const [searchInput, setSearchInput] = useState(''); @@ -76,7 +92,7 @@ export const RulesListPage = () => { setPage(1); }, [debouncedSearch, filter]); - const { data, isLoading, isError, error } = useFetchRules({ + const { data: rulesData, isLoading, isError, error } = useFetchRules({ page, perPage, filter, @@ -123,7 +139,7 @@ export const RulesListPage = () => { setFlyoutOpen(true)} data-test-subj="createRuleButton" > { { /> ) : null} + {flyoutOpen && ( + setFlyoutOpen(false)} + services={ruleFormServices} + /> + )}
); };