From 41953127ba2db0f7d857f3dbc6d740ad6a73d2d2 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 15:59:22 -0400 Subject: [PATCH 01/52] Add compose_discover flyout baseline from prototype --- .../compose_discover_chart.tsx | 149 ++++ .../compose_discover_child.tsx | 437 ++++++++++ .../compose_discover_flyout.tsx | 194 +++++ .../compose_discover_form.tsx | 796 ++++++++++++++++++ .../compose_discover_tabs.tsx | 230 +++++ .../horizontal_minimal_stepper.tsx | 115 +++ .../flyout/compose_discover/index.ts | 9 + .../flyout/compose_discover/query_summary.tsx | 53 ++ .../flyout/compose_discover/types.ts | 88 ++ .../use_compose_discover_state.ts | 231 +++++ .../compose_discover/use_esql_providers.ts | 58 ++ .../compose_discover/use_heuristic_split.ts | 103 +++ .../compose_discover/use_query_execution.ts | 173 ++++ .../use_split_query_completion.ts | 115 +++ .../pages/rules_list_page/rules_list_page.tsx | 37 +- 15 files changed, 2781 insertions(+), 7 deletions(-) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_chart.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_child.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_flyout.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_form.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_tabs.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/horizontal_minimal_stepper.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/index.ts create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/query_summary.tsx create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/types.ts create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_compose_discover_state.ts create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_esql_providers.ts create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_heuristic_split.ts create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_query_execution.ts create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_split_query_completion.ts 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/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} + /> + )}
); }; From 926d48e2df21e55c6decc4f34e23c014d6bd0252 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 16:01:03 -0400 Subject: [PATCH 02/52] A2: Stepped form state & types (UI-state reducer, SandboxTabConfig) --- .../flyout/compose_discover/types.ts | 25 +++++++++++-------- .../use_compose_discover_state.ts | 18 +++---------- 2 files changed, 17 insertions(+), 26 deletions(-) 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 index c4716c03dbcb9..accf4b4f6b97e 100644 --- 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 @@ -6,27 +6,30 @@ */ export type ComposeDiscoverMode = 'create' | 'edit'; -// 'no-recovery' is a prototype-only placeholder — not yet wired to the API + +/** 'no-recovery' disables recovery alerts. Not yet wired to the API — shown as disabled in the UI. */ 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. + * Describes which tabs the Discover Sandbox should show for the current step and context. * Computed from state by getSandboxTabConfig(). + * + * - single: No tracking enabled — one query editor, no tabs + * - base-alert: Tracking on, Alert Condition step — Base query + Alert query tabs + * - base-recovery: Recovery Condition step with custom recovery — Recovery query tab only + * - all-three: YAML mode — all three tabs always visible */ 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 + | { type: 'single' } + | { type: 'base-alert' } + | { type: 'base-recovery' } + | { type: 'all-three' }; export interface ComposeDiscoverState { mode: ComposeDiscoverMode; - // PROTOTYPE: layout option toggle - option: LayoutOption; step: number; tracking: boolean; fullQuery: string; @@ -35,6 +38,7 @@ export interface ComposeDiscoverState { recoveryBlock: string; recoveryType: RecoveryType; notificationsEnabled: boolean; + // Form value fields — migrated to RHF useForm in this PR name: string; tags: string[]; schedule: string; @@ -71,7 +75,6 @@ export type ComposeDiscoverAction = | { 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' } 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 index 79f9cb47647dc..4936fdea0ca27 100644 --- 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 @@ -20,7 +20,6 @@ const SAMPLE_QUERY = `FROM logs-* const createInitialState = (mode: ComposeDiscoverMode): ComposeDiscoverState => ({ mode, - option: 'opt1', step: 0, tracking: false, fullQuery: mode === 'create' ? SAMPLE_QUERY : '', @@ -49,8 +48,7 @@ const createInitialState = (mode: ComposeDiscoverMode): ComposeDiscoverState => * 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']; +export function getStepTitles(state: Pick): string[] { if (state.tracking) return ['Alert Condition', 'Recovery Condition', 'Details & Artifacts', 'Notifications']; return ['Alert Condition', 'Details & Artifacts', 'Notifications']; @@ -62,22 +60,14 @@ export function getStepTitles(state: Pick Date: Mon, 11 May 2026 16:01:18 -0400 Subject: [PATCH 03/52] Add useCreateRule hook --- .../public/hooks/use_create_rule.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_create_rule.ts diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_create_rule.ts b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_create_rule.ts new file mode 100644 index 0000000000000..edac9f1f62d44 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_create_rule.ts @@ -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 { useMutation, useQueryClient } from '@kbn/react-query'; +import { i18n } from '@kbn/i18n'; +import { useService, CoreStart } from '@kbn/core-di-browser'; +import type { CreateRuleData } from '@kbn/alerting-v2-schemas'; +import { RulesApi } from '../services/rules_api'; +import { ruleKeys } from './query_key_factory'; + +export const useCreateRule = () => { + const rulesApi = useService(RulesApi); + const { toasts } = useService(CoreStart('notifications')); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: CreateRuleData) => rulesApi.createRule(payload), + onSuccess: () => { + toasts.addSuccess( + i18n.translate('xpack.alertingV2.hooks.useCreateRule.successMessage', { + defaultMessage: 'Rule created successfully', + }) + ); + queryClient.invalidateQueries(ruleKeys.lists()); + queryClient.invalidateQueries(ruleKeys.tags()); + }, + onError: () => { + toasts.addDanger( + i18n.translate('xpack.alertingV2.hooks.useCreateRule.errorMessage', { + defaultMessage: 'Failed to create rule', + }) + ); + }, + }); +}; From acfab4243960aac1f2e954dfc4180947d0591d01 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 16:02:16 -0400 Subject: [PATCH 04/52] RHF migration: split form values from UI state, wire create/edit API --- .../compose_discover_flyout.tsx | 277 ++++++++++-------- 1 file changed, 150 insertions(+), 127 deletions(-) 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 index 71e749f4ffb3d..12c476f46f119 100644 --- 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 @@ -5,12 +5,11 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; import { - EuiBadge, EuiButton, EuiButtonEmpty, - EuiButtonGroup, EuiFlexGroup, EuiFlexItem, EuiFlyout, @@ -22,173 +21,197 @@ import { } from '@elastic/eui'; import { HorizontalMinimalStepper, type MinimalStep } from './horizontal_minimal_stepper'; import type { RuleFormServices } from '../../form/contexts/rule_form_context'; +import { RuleFormProvider } from '../../form/contexts/rule_form_context'; +import type { FormValues } from '../../form/types'; +import { + mapRuleResponseToFormValues, + mapFormValuesToCreateRequest, + mapFormValuesToUpdateRequest, +} from '../../form/utils/rule_request_mappers'; 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'; +// These hooks live in the plugin, not the package — imported via the plugin's hook layer +// when this flyout is rendered in the rules list page. +// For now they are passed as props to keep the package boundary clean. export interface ComposeDiscoverFlyoutProps { historyKey: symbol; mode?: ComposeDiscoverMode; - initialQuery?: string; + /** The existing rule — provided when mode === 'edit'. Used to seed the RHF form. */ + rule?: Parameters[0]; onClose: () => void; services: RuleFormServices; + /** Called with the create payload when the user submits in create mode. */ + onCreateRule: (payload: ReturnType) => void; + /** Called with id + update payload when the user submits in edit mode. */ + onUpdateRule?: (id: string, payload: ReturnType) => void; + /** True while a create/update mutation is in flight. */ + isSaving?: boolean; } const FLYOUT_TITLE_ID = 'composeDiscoverFlyoutTitle'; -const YAML_TOGGLE_OPTIONS = [ - { id: 'form', label: 'Form', iconType: 'tableDensityNormal' }, - { id: 'yaml', label: 'YAML', iconType: 'editorCodeBlock' }, -]; - +const EMPTY_FORM_VALUES: FormValues = { + kind: 'alert', + metadata: { name: '', enabled: true, description: '', tags: [] }, + timeField: '@timestamp', + schedule: { every: '1m', lookback: '5m' }, + evaluation: { query: { base: '' } }, + grouping: undefined, + recoveryPolicy: { type: 'no_breach' }, + stateTransition: undefined, + stateTransitionAlertDelayMode: 'immediate', + stateTransitionRecoveryDelayMode: 'immediate', + artifacts: [], +}; export const ComposeDiscoverFlyout: React.FC = ({ historyKey, mode = 'create', - initialQuery, + rule, onClose, services, + onCreateRule, + onUpdateRule, + isSaving = false, }) => { - const [state, dispatch] = useComposeDiscoverState(mode); + // ── UI state (step navigation, sandbox open/close, tab selection, etc.) ── + const [uiState, dispatch] = useComposeDiscoverState(mode); - React.useEffect(() => { - if (initialQuery) { - dispatch({ type: 'SET_FULL_QUERY', query: initialQuery }); + // ── Form values (submitted to the API) ── + const defaultValues = useMemo(() => { + if (rule) { + const mapped = mapRuleResponseToFormValues(rule); + return { + kind: mapped.kind ?? 'alert', + metadata: { + name: mapped.metadata?.name ?? '', + enabled: mapped.metadata?.enabled ?? true, + description: mapped.metadata?.description ?? '', + owner: mapped.metadata?.owner, + tags: mapped.metadata?.tags ?? [], + }, + timeField: mapped.timeField ?? '@timestamp', + schedule: { every: mapped.schedule?.every ?? '1m', lookback: mapped.schedule?.lookback ?? '5m' }, + evaluation: { query: { base: mapped.evaluation?.query?.base ?? '' } }, + grouping: mapped.grouping, + recoveryPolicy: mapped.recoveryPolicy ?? { type: 'no_breach' }, + stateTransition: mapped.stateTransition, + stateTransitionAlertDelayMode: mapped.stateTransitionAlertDelayMode ?? 'immediate', + stateTransitionRecoveryDelayMode: mapped.stateTransitionRecoveryDelayMode ?? 'immediate', + artifacts: mapped.artifacts ?? [], + }; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + return EMPTY_FORM_VALUES; + }, [rule]); + + const methods = useForm({ mode: 'onBlur', defaultValues }); 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); + const stepTitles = getStepTitles({ tracking: uiState.tracking }); + const isLastStep = uiState.step === stepTitles.length - 1; + const tabConfig = getSandboxTabConfig(uiState); + + const handleSubmit = methods.handleSubmit((values) => { + if (isCreate) { + onCreateRule(mapFormValuesToCreateRequest(values)); + } else if (rule && onUpdateRule) { + onUpdateRule((rule as { id: string }).id, mapFormValuesToUpdateRequest(values)); + } + }); 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" - /> - - + ({ + title: stepTitle, + status: i < uiState.step ? 'complete' : i === uiState.step ? 'current' : 'incomplete', + }))} + /> + {/* YAML mode coming in follow-up PR */}
- + - {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 - - - )} + + + Cancel + + + + {uiState.step > 0 && ( - {isLastStep ? ( - - {isCreate ? 'Create rule' : 'Save rule'} - - ) : ( - dispatch({ type: 'GO_NEXT' })} - data-test-subj="composeDiscoverNext" - > - Next - - )} + 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' })} - /> - )} -
+ + {uiState.childOpen && ( + dispatch({ type: 'CLOSE_CHILD' })} + /> + )} +
+ + ); }; - From c75f23c61cc6b528af09e2eb044b8d2652553d8e Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 16:03:54 -0400 Subject: [PATCH 05/52] Use exported field groups in form steps (RuleDetailsFieldGroup, RuleExecutionFieldGroup) --- .../compose_discover_form.tsx | 33 ++----------------- 1 file changed, 3 insertions(+), 30 deletions(-) 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 index 73eb06794692d..f1e5d046dad0c 100644 --- 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 @@ -34,6 +34,7 @@ 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'; +import { RuleDetailsFieldGroup, RuleExecutionFieldGroup } from '../../form'; interface ComposeDiscoverFormProps { state: ComposeDiscoverState; @@ -459,7 +460,7 @@ function AlertConditionStep({ )} - + ); } @@ -524,35 +525,7 @@ function DetailsAndArtifactsStep({ }) { 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" - /> - + From beb251470c345d76254f33d012d81d306dc090c4 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 16:04:41 -0400 Subject: [PATCH 06/52] Wire edit mode and create/update mutations in rules list page --- .../pages/rules_list_page/rules_list_page.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 34ca4bd11cb46..e050c41b500fd 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 @@ -29,6 +29,8 @@ 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 { useCreateRule } from '../../hooks/use_create_rule'; +import { useUpdateRule } from '../../hooks/use_update_rule'; import { useFetchRuleTags } from '../../hooks/use_fetch_rule_tags'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; @@ -62,7 +64,10 @@ export const RulesListPage = () => { useBreadcrumbs('rules_list'); const [flyoutOpen, setFlyoutOpen] = useState(false); + const [editRule, setEditRule] = useState(null); const historyKey = useMemo(() => Symbol('ruleAuthoring'), []); + const createRuleMutation = useCreateRule(); + const updateRuleMutation = useUpdateRule(); const ruleFormServices = useMemo( () => ({ http, data, dataViews, notifications, application, lens }), [http, data, dataViews, notifications, application, lens] @@ -214,8 +219,17 @@ export const RulesListPage = () => { {flyoutOpen && ( setFlyoutOpen(false)} + mode={editRule ? 'edit' : 'create'} + rule={editRule ?? undefined} + onClose={() => { setFlyoutOpen(false); setEditRule(null); }} services={ruleFormServices} + onCreateRule={(payload) => + createRuleMutation.mutate(payload, { onSuccess: () => { setFlyoutOpen(false); } }) + } + onUpdateRule={(id, payload) => + updateRuleMutation.mutate({ id, payload }, { onSuccess: () => { setFlyoutOpen(false); setEditRule(null); } }) + } + isSaving={createRuleMutation.isLoading || updateRuleMutation.isLoading} /> )} From 4b1a1f4dedacd334bbad258c249c8bacf9f0bae0 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 16:43:17 -0400 Subject: [PATCH 07/52] Simplify to single editor: remove tracking, split, and recovery condition (follow-up PR) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover_child.tsx | 125 +----- .../compose_discover_flyout.tsx | 2 +- .../compose_discover_form.tsx | 410 +----------------- .../compose_discover_tabs.tsx | 220 +--------- .../flyout/compose_discover/types.ts | 38 +- .../use_compose_discover_state.ts | 123 +----- 6 files changed, 73 insertions(+), 845 deletions(-) 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 index 7e54b38d435e5..74c2bf2e074fb 100644 --- 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 @@ -35,7 +35,6 @@ 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'; @@ -44,9 +43,8 @@ interface ComposeDiscoverChildProps { state: ComposeDiscoverState; dispatch: React.Dispatch; services: RuleFormServices; - /** Determines which tabs to show in the editor. Computed by getSandboxTabConfig() in the parent. */ + /** Always `{ type: 'single' }` for now. */ tabConfig: SandboxTabConfig; - onClose: () => void; } @@ -60,40 +58,10 @@ 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); @@ -104,11 +72,10 @@ export const ComposeDiscoverChild: React.FC = ({ const timeRange = useMemo(() => ({ from: dateStart, to: dateEnd }), [dateStart, dateEnd]); - const activeQuery = getActiveQuery(state, localQuery, tabConfig); + // Single-editor mode: always use localQuery + const activeQuery = localQuery; // 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, @@ -141,28 +108,18 @@ export const ComposeDiscoverChild: React.FC = ({ const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); - e.stopPropagation(); // stops the event reaching Monaco's own handlers + e.stopPropagation(); 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 }); - } + dispatch({ type: 'COMMIT_CHILD_QUERY', fullQuery: localQuery }); onClose(); - }, [tabConfig, state, localQuery, dispatch, onClose]); + }, [localQuery, dispatch, onClose]); const gridColumns: EuiDataGridColumn[] = useMemo( () => @@ -223,15 +180,7 @@ export const ComposeDiscoverChild: React.FC = ({ > -

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

+

{state.queryCommitted ? 'Edit alert query' : 'Define alert query'}

{!state.queryCommitted && ( @@ -242,31 +191,7 @@ export const ComposeDiscoverChild: React.FC = ({
- {/* ── 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 ──────── */} + {/* ── 1. Search / date picker / time field row — one line ──────── */}
@@ -309,27 +234,23 @@ export const ComposeDiscoverChild: React.FC = ({
- {/* ── 3. Editor — bordered panel ──────────────────────────────── */} + {/* ── 2. Editor — bordered panel ──────────────────────────────── */} - {tabConfig.type !== 'single' ? ( - - ) : ( - - )} + - {/* ── 4. Footer stats ─────────────────────────────────────────── */} + {/* ── 3. Footer stats ─────────────────────────────────────────── */} {hasRun && !isLoading && !isError && (
@@ -338,7 +259,7 @@ export const ComposeDiscoverChild: React.FC = ({
)} - {/* ── 5. Results ──────────────────────────────────────────────── */} + {/* ── 4. Results ──────────────────────────────────────────────── */}
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 index 12c476f46f119..b41e86ba320df 100644 --- 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 @@ -112,7 +112,7 @@ export const ComposeDiscoverFlyout: React.FC = ({ const isCreate = mode === 'create'; const title = isCreate ? 'Create alert rule' : 'Edit alert rule'; - const stepTitles = getStepTitles({ tracking: uiState.tracking }); + const stepTitles = getStepTitles(); const isLastStep = uiState.step === stepTitles.length - 1; const tabConfig = getSandboxTabConfig(uiState); 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 index f1e5d046dad0c..3852f7e3fa917 100644 --- 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 @@ -5,13 +5,11 @@ * 2.0. */ -import React, { useCallback, useMemo, type FC } from 'react'; +import React, { useMemo } from 'react'; import { CodeEditor } from '@kbn/code-editor'; -import { ESQL_LANG_ID } from '@kbn/monaco'; import { EuiBadge, EuiButton, - EuiButtonGroup, EuiCallOut, EuiComboBox, EuiFieldNumber, @@ -21,16 +19,15 @@ import { EuiFormRow, EuiHorizontalRule, EuiPanel, + EuiRadioGroup, EuiSelect, EuiSpacer, - EuiSuperSelect, EuiSwitch, EuiText, EuiTitle, } from '@elastic/eui'; -import type { ComposeDiscoverState, ComposeDiscoverAction, DelayMode, RecoveryType } from './types'; +import type { ComposeDiscoverState, ComposeDiscoverAction } 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'; @@ -42,242 +39,25 @@ interface ComposeDiscoverFormProps { 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, + query: state.queryCommitted ? state.fullQuery : '', http: services.http, dataViews: services.dataViews, }); @@ -290,21 +70,6 @@ function AlertConditionStep({ 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 ( <> @@ -329,7 +94,7 @@ function AlertConditionStep({ Open query editor - ) : !state.tracking ? ( + ) : ( <> @@ -343,41 +108,6 @@ function AlertConditionStep({ 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 */} @@ -408,114 +138,11 @@ function AlertConditionStep({ /> - - - - - {/* 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, @@ -684,33 +311,24 @@ function NotificationsStep({ // ── 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 ', '')}" + query: + base: | + ${indent(fullQuery)} 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}" } @@ -742,19 +360,11 @@ export const ComposeDiscoverForm: React.FC = ({ state, ); } - const stepTitles = getStepTitles(state); + const stepTitles = getStepTitles(); const currentStepName = stepTitles[state.step] ?? ''; if (currentStepName === 'Alert Condition') { - return ; - } - - if (currentStepName === 'Query Condition') { - return ; - } - - if (currentStepName === 'Recovery Condition') { - return ; + return ; } if (currentStepName === 'Details & Artifacts') { 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 index df9245f6a8373..fb7dd93b9fd8c 100644 --- 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 @@ -6,225 +6,47 @@ */ 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 { ESQL_LANG_ID } from '@kbn/monaco'; +import type { ComposeDiscoverState, ComposeDiscoverAction, 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. */ + /** Controls which tabs are shown. Always `{ type: 'single' }` for now. */ 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). */ + /** Required for future 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( +/** + * Renders the single ES|QL editor for the Discover Sandbox. + * Tab variants (Base/Alert/Recovery) are added in the custom recovery follow-up PR. + */ +export const ComposeDiscoverTabs: React.FC = ({ + state, + dispatch, +}) => { + const editorOptions = 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()} - + dispatch({ type: 'SET_FULL_QUERY', query: val })} + height="100%" + options={editorOptions} + /> ); }; 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 index accf4b4f6b97e..f05bc50a2471e 100644 --- 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 @@ -7,36 +7,20 @@ export type ComposeDiscoverMode = 'create' | 'edit'; -/** 'no-recovery' disables recovery alerts. Not yet wired to the API — shown as disabled in the UI. */ -export type RecoveryType = 'default' | 'no-recovery' | 'custom'; - export type QueryTab = 'base' | 'alert' | 'recovery'; export type DelayMode = 'immediate' | 'breaches' | 'duration'; /** - * Describes which tabs the Discover Sandbox should show for the current step and context. - * Computed from state by getSandboxTabConfig(). - * - * - single: No tracking enabled — one query editor, no tabs - * - base-alert: Tracking on, Alert Condition step — Base query + Alert query tabs - * - base-recovery: Recovery Condition step with custom recovery — Recovery query tab only - * - all-three: YAML mode — all three tabs always visible + * Describes which tabs the Discover Sandbox should show. + * Always `{ type: 'single' }` for now — tabs (Base/Alert/Recovery) are added in the + * custom recovery follow-up PR. */ -export type SandboxTabConfig = - | { type: 'single' } - | { type: 'base-alert' } - | { type: 'base-recovery' } - | { type: 'all-three' }; +export type SandboxTabConfig = { type: 'single' }; export interface ComposeDiscoverState { mode: ComposeDiscoverMode; step: number; - tracking: boolean; fullQuery: string; - baseQuery: string; - alertBlock: string; - recoveryBlock: string; - recoveryType: RecoveryType; notificationsEnabled: boolean; // Form value fields — migrated to RHF useForm in this PR name: string; @@ -59,12 +43,6 @@ 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 } @@ -82,10 +60,4 @@ export type ComposeDiscoverAction = | { 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; - }; + | { type: 'COMMIT_CHILD_QUERY'; fullQuery: 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 index 4936fdea0ca27..90317b37c8025 100644 --- 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 @@ -12,7 +12,6 @@ import type { ComposeDiscoverMode, SandboxTabConfig, } from './types'; -import { guessRecoveryBlock } from './use_heuristic_split'; const SAMPLE_QUERY = `FROM logs-* | STATS count = COUNT(*) BY host.name @@ -21,12 +20,7 @@ const SAMPLE_QUERY = `FROM logs-* const createInitialState = (mode: ComposeDiscoverMode): ComposeDiscoverState => ({ mode, step: 0, - tracking: false, fullQuery: mode === 'create' ? SAMPLE_QUERY : '', - baseQuery: '', - alertBlock: '', - recoveryBlock: '', - recoveryType: 'default', notificationsEnabled: false, name: '', tags: [], @@ -45,43 +39,22 @@ const createInitialState = (mode: ComposeDiscoverMode): ComposeDiscoverState => }); /** - * Returns the ordered list of step titles for the current state. - * Used for the stepper display and step routing in the form. + * Returns the ordered list of step titles. Always 3 steps for PR B. + * Tracking / Recovery Condition step added in the custom recovery follow-up. */ -export function getStepTitles(state: Pick): string[] { - if (state.tracking) - return ['Alert Condition', 'Recovery Condition', 'Details & Artifacts', 'Notifications']; +export function getStepTitles(): string[] { return ['Alert Condition', 'Details & Artifacts', 'Notifications']; } /** - * Returns the SandboxTabConfig for the current state — determines which tabs - * the Discover Sandbox child flyout should display. + * Returns the SandboxTabConfig for the current state. + * Always single for now — tabs (Base/Alert/Recovery) added in custom recovery follow-up. */ -export function getSandboxTabConfig(state: ComposeDiscoverState): SandboxTabConfig { - if (state.yamlMode) return { type: 'all-three' }; - - const stepTitles = getStepTitles(state); - const currentStepName = stepTitles[state.step] ?? ''; - - if (currentStepName === 'Recovery Condition' && state.recoveryType === 'custom') { - return { type: 'base-recovery' }; - } - if (currentStepName === 'Alert Condition' && state.tracking) { - return { type: 'base-alert' }; - } +export function getSandboxTabConfig(_state: ComposeDiscoverState): SandboxTabConfig { + // Tabs (Base/Alert/Recovery) added in custom recovery follow-up 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 @@ -93,51 +66,6 @@ function reducer( 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({ 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': @@ -160,16 +88,15 @@ function reducer( return { ...state, yamlMode: action.enabled, - // YAML mode force-opens the Sandbox with all 3 tabs + // YAML mode force-opens the Sandbox childOpen: action.enabled ? true : state.childOpen, - activeTab: action.enabled ? 'base' : state.activeTab, }; case 'SET_NOTIFICATIONS_ENABLED': return { ...state, notificationsEnabled: action.enabled }; case 'SET_STEP': return { ...state, step: action.step }; case 'GO_NEXT': { - const steps = getStepTitles(state); + const steps = getStepTitles(); const nextStep = Math.min(state.step + 1, steps.length - 1); return { ...state, step: nextStep, childOpen: false }; } @@ -177,38 +104,14 @@ function reducer( 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 'OPEN_CHILD': + return { ...state, childOpen: true }; + case 'OPEN_CHILD_FOR_STEP': + return { ...state, step: action.step, childOpen: true }; 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; } From 118f935d7a59e07d767f0020ade2c0f2a9a8e05c Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 19:43:14 -0400 Subject: [PATCH 08/52] Fix critical bugs: RHF bridge, stubs, console.debug, Quick Edit replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RHF bridge (was not connected): - Add useEffect hooks in ComposeDiscoverFlyout to sync fullQuery, timeField, and groupFields from the UI reducer into RHF via setValue() whenever they change. This ensures mapFormValuesToCreateRequest/ UpdateRequest receives actual user input instead of default values. - Replace `rule as { id: string }` cast with explicit `ruleId` prop. Stubs (were silent no-ops): - DetailsAndArtifactsStep: remove hardcoded no-data select; mark runbook/dashboard fields as disabled with TODO comment (#268770). - NotificationsStep: replace all uncontrolled/hardcoded fields with an explicit EuiCallOut placeholder — no longer silently discards input. console.debug: - Remove two console.debug calls in use_query_execution.ts that were suppressed with eslint-disable-next-line comments. Quick Edit replacement + Create in Flyout: - Add onEditInFlyout prop to RulesListTableContainer; when provided, pencil icon routes to that handler instead of QuickEditRuleFlyout. - RulesListPage passes onEditInFlyout to open ComposeDiscoverFlyout in edit mode (setEditRule + setFlyoutOpen) from the pencil icon. - Replace the single "Create rule" button with two buttons: "Create rule" (fill, navigates to full-page form — existing flow) "Create in flyout" (opens ComposeDiscoverFlyout in create mode). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover_flyout.tsx | 30 +++- .../compose_discover_form.tsx | 146 +++--------------- .../compose_discover/use_query_execution.ts | 6 - .../pages/rules_list_page/rules_list_page.tsx | 43 ++++-- .../rules_list_table_container.tsx | 23 ++- 5 files changed, 97 insertions(+), 151 deletions(-) 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 index b41e86ba320df..bfbd3afbc4479 100644 --- 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { EuiButton, @@ -41,6 +41,8 @@ export interface ComposeDiscoverFlyoutProps { mode?: ComposeDiscoverMode; /** The existing rule — provided when mode === 'edit'. Used to seed the RHF form. */ rule?: Parameters[0]; + /** The ID of the rule being edited. Required when mode === 'edit'. */ + ruleId?: string; onClose: () => void; services: RuleFormServices; /** Called with the create payload when the user submits in create mode. */ @@ -71,6 +73,7 @@ export const ComposeDiscoverFlyout: React.FC = ({ historyKey, mode = 'create', rule, + ruleId, onClose, services, onCreateRule, @@ -116,11 +119,32 @@ export const ComposeDiscoverFlyout: React.FC = ({ const isLastStep = uiState.step === stepTitles.length - 1; const tabConfig = getSandboxTabConfig(uiState); + // Sync reducer-owned query fields into RHF whenever the user commits a query + // from the Discover Sandbox. This bridges the gap between the two state systems + // so that mapFormValuesToCreateRequest/UpdateRequest receives current values. + useEffect(() => { + if (uiState.queryCommitted && uiState.fullQuery) { + methods.setValue('evaluation', { query: { base: uiState.fullQuery } }); + } + }, [uiState.fullQuery, uiState.queryCommitted, methods]); + + // Sync time field and group fields from the reducer into RHF as they change. + useEffect(() => { + methods.setValue('timeField', uiState.timeField); + }, [uiState.timeField, methods]); + + useEffect(() => { + methods.setValue( + 'grouping', + uiState.groupFields.length > 0 ? { fields: uiState.groupFields } : undefined + ); + }, [uiState.groupFields, methods]); + const handleSubmit = methods.handleSubmit((values) => { if (isCreate) { onCreateRule(mapFormValuesToCreateRequest(values)); - } else if (rule && onUpdateRule) { - onUpdateRule((rule as { id: string }).id, mapFormValuesToUpdateRequest(values)); + } else if (ruleId && onUpdateRule) { + onUpdateRule(ruleId, mapFormValuesToUpdateRequest(values)); } }); 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 index 3852f7e3fa917..c8ec6182d0eb1 100644 --- 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 @@ -8,21 +8,17 @@ import React, { useMemo } from 'react'; import { CodeEditor } from '@kbn/code-editor'; import { - EuiBadge, EuiButton, EuiCallOut, EuiComboBox, - EuiFieldNumber, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiHorizontalRule, EuiPanel, - EuiRadioGroup, EuiSelect, EuiSpacer, - EuiSwitch, EuiText, EuiTitle, } from '@elastic/eui'; @@ -152,32 +148,19 @@ function DetailsAndArtifactsStep({ }) { return ( <> + {/* Name, description, tags — connected to RHF via useFormContext() internally */} - -

No data behavior

-
- - - - - - - -

Artifacts

+ {/* TODO (#268770): wire runbook URL and dashboard link to FormValues.artifacts */} Optional}> - + @@ -187,124 +170,33 @@ function DetailsAndArtifactsStep({ fullWidth labelAppend={Optional} > - + ); } +// TODO (#268770): Notifications step — wire workflow selector and notification policy fields +// to FormValues once the action policy API integration is in place. function NotificationsStep({ - state, - dispatch, + state: _state, + dispatch: _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. -

-
- - )} - + +

+ Notification policies will be configurable here. Rules are created without notifications + until this step is wired. +

+
); } 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 index fd5349db24624..e7a6bdadc4491 100644 --- 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 @@ -81,9 +81,6 @@ export const useQueryExecution = ({ 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, @@ -122,9 +119,6 @@ export const useQueryExecution = ({ 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) { 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 e050c41b500fd..0913e012baad1 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 @@ -141,17 +141,33 @@ export const RulesListPage = () => { /> } rightSideItems={[ - setFlyoutOpen(true)} - data-test-subj="createRuleButton" - > - - , + + + {/* Primary create button — navigates to the full-page form (existing flow) */} + + + + + + {/* Flyout create — opens the new stepped flyout experience */} + setFlyoutOpen(true)} + data-test-subj="createRuleFlyoutButton" + > + + + + , ]} /> @@ -213,6 +229,10 @@ export const RulesListPage = () => { sortDirection={sortDirection} isLoading={isLoading} onTableChange={onTableChange} + onEditInFlyout={(rule) => { + setEditRule(rule); + setFlyoutOpen(true); + }} /> ) : null} @@ -221,6 +241,7 @@ export const RulesListPage = () => { historyKey={historyKey} mode={editRule ? 'edit' : 'create'} rule={editRule ?? undefined} + ruleId={editRule?.id} onClose={() => { setFlyoutOpen(false); setEditRule(null); }} services={ruleFormServices} onCreateRule={(payload) => diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table_container.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table_container.tsx index 56b78688213ea..ce5ef5438c15d 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table_container.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table_container.tsx @@ -32,6 +32,11 @@ export interface RulesListTableContainerProps { sortDirection?: 'asc' | 'desc'; isLoading: boolean; onTableChange: (criteria: Criteria) => void; + /** + * When provided, the pencil icon routes to this handler instead of opening + * the internal QuickEditRuleFlyout. Use to redirect to the new stepped flyout. + */ + onEditInFlyout?: (rule: RuleApiResponse) => void; } export const RulesListTableContainer: React.FC = ({ @@ -46,6 +51,7 @@ export const RulesListTableContainer: React.FC = ( sortDirection, isLoading, onTableChange, + onEditInFlyout, }) => { const { navigateToUrl } = useService(CoreStart('application')); const { basePath } = useService(CoreStart('http')); @@ -148,8 +154,12 @@ export const RulesListTableContainer: React.FC = ( setExpandedRuleId(r.id); }} onQuickEdit={(r) => { - setExpandedRuleId(null); - setQuickEditRuleId(r.id); + if (onEditInFlyout) { + onEditInFlyout(r); + } else { + setExpandedRuleId(null); + setQuickEditRuleId(r.id); + } }} onEdit={(r) => navigateToUrl(basePath.prepend(paths.ruleEdit(r.id)))} onClone={(r) => @@ -166,8 +176,13 @@ export const RulesListTableContainer: React.FC = ( rule={expandedRule} onClose={() => setExpandedRuleId(null)} onQuickEdit={(r) => { - setExpandedRuleId(null); - setQuickEditRuleId(r.id); + if (onEditInFlyout) { + setExpandedRuleId(null); + onEditInFlyout(r); + } else { + setExpandedRuleId(null); + setQuickEditRuleId(r.id); + } }} onEdit={(r) => navigateToUrl(basePath.prepend(paths.ruleEdit(r.id)))} onClone={(r) => From 2c7aacb3932a66ebd8d27752f7beadc5ffe71c16 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 20:56:26 -0400 Subject: [PATCH 09/52] =?UTF-8?q?Remove=20HorizontalMinimalStepper=20from?= =?UTF-8?q?=20PR=20B=20=E2=80=94=20ships=20in=20PR=20A=20separately?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover_flyout.tsx | 11 +- .../horizontal_minimal_stepper.tsx | 115 ------------------ .../alerting-v2-rule-form/index.ts | 4 + 3 files changed, 5 insertions(+), 125 deletions(-) delete mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/horizontal_minimal_stepper.tsx 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 index bfbd3afbc4479..d9007f95cfb18 100644 --- 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 @@ -19,7 +19,6 @@ import { EuiSpacer, EuiTitle, } from '@elastic/eui'; -import { HorizontalMinimalStepper, type MinimalStep } from './horizontal_minimal_stepper'; import type { RuleFormServices } from '../../form/contexts/rule_form_context'; import { RuleFormProvider } from '../../form/contexts/rule_form_context'; import type { FormValues } from '../../form/types'; @@ -164,15 +163,7 @@ export const ComposeDiscoverFlyout: React.FC = ({

{title}

- - - ({ - title: stepTitle, - status: i < uiState.step ? 'complete' : i === uiState.step ? 'current' : 'incomplete', - }))} - /> - {/* YAML mode coming in follow-up PR */} + {/* Step indicator coming in PR A — HorizontalMinimalStepper */} 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 deleted file mode 100644 index bf3c247b37c00..0000000000000 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/horizontal_minimal_stepper.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { 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/index.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/index.ts index 82c6c81f47056..6f41b9830cf53 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 @@ -8,6 +8,10 @@ // Pre-composed flyouts (lazy loaded) - recommended for most use cases export { DynamicRuleFormFlyout, StandaloneRuleFormFlyout } from './flyout'; +// Compose Discover flyout — stepped Edit Form + Discover Sandbox +export { ComposeDiscoverFlyout } from './flyout/compose_discover'; +export type { ComposeDiscoverFlyoutProps } from './flyout/compose_discover'; + // Lazy components (without Suspense wrapper) - for consumers who need full control export { LazyDynamicRuleFormFlyout, From ca7ac1cc7dc8e651d65f1192bc09bfe8bf55073e Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 21:24:50 -0400 Subject: [PATCH 10/52] Route form steps by index, not string title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, ComposeDiscoverForm routed to the correct step component by comparing `stepTitles[state.step]` against hardcoded string literals: if (currentStepName === 'Alert Condition') { ... } if (currentStepName === 'Details & Artifacts') { ... } This is fragile: renaming a step title in getStepTitles() would silently break rendering — the component would fall through to `return null` with no error. The user would see a blank flyout body with no indication of what went wrong. Replace with a switch on the numeric step index. The step index is the authoritative source of which step is active (it lives in uiState.step), so routing on it is both more correct and more resilient to copy changes. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover_form.tsx | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) 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 index c8ec6182d0eb1..db5139c9c4213 100644 --- 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 @@ -252,20 +252,15 @@ export const ComposeDiscoverForm: React.FC = ({ state, ); } - const stepTitles = getStepTitles(); - const currentStepName = stepTitles[state.step] ?? ''; - - if (currentStepName === 'Alert Condition') { - return ; - } - - if (currentStepName === 'Details & Artifacts') { - return ; + // Route by step index, not by string title — avoids silent breakage if step titles change + switch (state.step) { + case 0: + return ; + case 1: + return ; + case 2: + return ; + default: + return null; } - - if (currentStepName === 'Notifications') { - return ; - } - - return null; }; From 0fc4e80dc6340fc4f99594eec48b576787deb3be Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 21:25:25 -0400 Subject: [PATCH 11/52] =?UTF-8?q?Fix=20useEsqlAutocomplete=20provider=20li?= =?UTF-8?q?fecycle=20=E2=80=94=20remove=20module-level=20singleton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation used a module-level singleton: let registeredDisposables: monaco.IDisposable[] | null = null; function registerProviders(callbacks) { if (registeredDisposables) return; // ← the problem ... } This caused two bugs: 1. React Strict Mode double-invokes effects (mount → unmount → mount in dev). The cleanup on first unmount set registeredDisposables = null, but the guard on re-mount re-registered correctly. However, if a second component instance mounted before the first unmounted, the guard caused the second instance to skip registration entirely, leaving it with no autocomplete. 2. Stale callbacks: the singleton captured callbacks from the first render. If services changed after mount, the registered providers continued using the old callbacks with no way to update them short of a full unmount. Fix: register providers per-hook-instance with empty deps (register once, clean up on unmount). Callbacks are kept current via a ref so providers always delegate to the latest values without needing to be re-registered. The stable wrapper object delegates getSources/getColumnsFor to the ref, giving Monaco providers a stable reference while the underlying callbacks can change freely. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover/use_esql_providers.ts | 90 +++++++++++-------- 1 file changed, 55 insertions(+), 35 deletions(-) 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 index c6ab8c313656c..0e276e0121ee7 100644 --- 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 @@ -5,40 +5,30 @@ * 2.0. */ -import { useEffect } from 'react'; +import { useEffect, useRef } 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; -} - +/** + * Registers ES|QL Monaco language providers (autocomplete, signature help, hover) + * for the lifetime of the component that calls this hook. + * + * Providers are registered per-hook-instance rather than via a module-level singleton. + * This avoids two problems with the previous singleton pattern: + * + * 1. React Strict Mode double-invokes effects (mount → unmount → mount). The old + * `if (registeredDisposables) return` guard caused the second mount to skip + * registration entirely, leaving the editor with no autocomplete. + * + * 2. Multiple concurrent instances would share one set of providers, so the first + * unmount would dispose providers that the second instance still needed. + * + * Callbacks are stored in a ref so they stay current across renders without + * causing the effect to re-run on every render. The effect only re-runs when + * the callbacks object reference changes (i.e. when services change). + */ export const useEsqlAutocomplete = (services: RuleFormServices) => { const callbacks = useEsqlCallbacks({ application: services.application, @@ -46,13 +36,43 @@ export const useEsqlAutocomplete = (services: RuleFormServices) => { search: services.data.search.search, }); + // Keep callbacks ref current so providers always use the latest without + // needing to be re-registered on every render. + const callbacksRef = useRef(callbacks); + callbacksRef.current = callbacks; + useEffect(() => { - registerProviders(callbacks); + const stableCallbacks: ESQLCallbacks = { + getSources: (...args) => callbacksRef.current.getSources?.(...args), + getColumnsFor: (...args) => callbacksRef.current.getColumnsFor?.(...args), + }; + + const disposables: monaco.IDisposable[] = []; + + const suggestion = ESQLLang.getSuggestionProvider?.(stableCallbacks); + if (suggestion) { + disposables.push( + monaco.languages.registerCompletionItemProvider(ESQL_LANG_ID, suggestion) + ); + } + + const signature = ESQLLang.getSignatureProvider?.(stableCallbacks); + if (signature) { + disposables.push( + monaco.languages.registerSignatureHelpProvider(ESQL_LANG_ID, signature) + ); + } + + const hover = ESQLLang.getHoverProvider?.(stableCallbacks); + if (hover) { + disposables.push(monaco.languages.registerHoverProvider(ESQL_LANG_ID, hover)); + } + return () => { - if (registeredDisposables) { - registeredDisposables.forEach((d) => d.dispose()); - registeredDisposables = null; - } + disposables.forEach((d) => d.dispose()); }; - }, [callbacks]); + // Empty deps: register once on mount, clean up on unmount. + // Callbacks stay current via callbacksRef without triggering re-registration. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); }; From 9e4b2efeba3ef7ce4b7b62349a1212e99039f609 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 21:26:04 -0400 Subject: [PATCH 12/52] Fix misleading row count display in Discover Sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer stat previously showed "N documents queried" and the results header showed "N results". Both were using totalRowCount from useQueryExecution, which is the number of rows in the ES|QL response — capped at MAX_ROWS (500). ES|QL does not return a true total count without a separate COUNT(*) query. The old labels implied to users that N was the total number of matching documents in the index, which is incorrect. A query matching 50,000 documents would show "500 documents queried" because ES|QL only returns the first 500 rows. Change both labels to "N rows returned" / "N rows" which accurately describes what the number represents: rows in the current response, not total matching documents. This is consistent with how Discover itself presents ES|QL result counts. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../flyout/compose_discover/compose_discover_child.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 74c2bf2e074fb..b0ae5c7958129 100644 --- 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 @@ -254,7 +254,7 @@ export const ComposeDiscoverChild: React.FC = ({ {hasRun && !isLoading && !isError && (
- {totalRowCount.toLocaleString()} document{totalRowCount !== 1 ? 's' : ''} queried + {totalRowCount.toLocaleString()} row{totalRowCount !== 1 ? 's' : ''} returned
)} @@ -314,7 +314,7 @@ export const ComposeDiscoverChild: React.FC = ({ - {totalRowCount} {totalRowCount === 1 ? 'result' : 'results'} + {totalRowCount} {totalRowCount === 1 ? 'row' : 'rows'} From 9e5c46a0019e773a12c27e0451a91f4848c70d58 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 21:29:21 -0400 Subject: [PATCH 13/52] =?UTF-8?q?Use=20'results'=20label=20to=20match=20Di?= =?UTF-8?q?scover=20=E2=80=94=20ES|QL=20has=20no=20total=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amends the row count label to use 'result'/'results' which matches the exact terminology used by Discover's hits counter component (discover.hitsCounter.resultLabel / resultsLabel). ES|QL responses contain only the returned rows — there is no total hit count equivalent to hits.total in a regular ES search. Discover handles this the same way: it displays the count of rows in the current response. 'N results' is accurate and consistent with the rest of the product. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../flyout/compose_discover/compose_discover_child.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index b0ae5c7958129..2a9ba8b1619f2 100644 --- 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 @@ -254,7 +254,7 @@ export const ComposeDiscoverChild: React.FC = ({ {hasRun && !isLoading && !isError && (
- {totalRowCount.toLocaleString()} row{totalRowCount !== 1 ? 's' : ''} returned + {totalRowCount.toLocaleString()} {totalRowCount === 1 ? 'result' : 'results'}
)} @@ -314,7 +314,7 @@ export const ComposeDiscoverChild: React.FC = ({ - {totalRowCount} {totalRowCount === 1 ? 'row' : 'rows'} + {totalRowCount.toLocaleString()} {totalRowCount === 1 ? 'result' : 'results'} From a18e547db8ab67abdb76fc675edd5bf55552f423 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 22:13:03 -0400 Subject: [PATCH 14/52] Persist Sandbox date range across open/close Previously, dateStart and dateEnd were local state in ComposeDiscoverChild. Every time the user closed the Sandbox (via Apply changes) and reopened it, the date range silently reset to 'now-15m / now', discarding whatever window the user had been exploring their data in. Move dateStart/dateEnd into the UI reducer as sandboxDateStart/sandboxDateEnd so the values survive Sandbox close/reopen. The child reads them from props (state.sandboxDateStart/End) and dispatches SET_SANDBOX_DATE_RANGE on change. The date range is intentionally NOT connected to FormValues.schedule.lookback. It is a preview window for testing the query in the Sandbox, not a rule configuration field. The rule's lookback is configured separately in the Evaluation section of the Edit Form. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../flyout/compose_discover/compose_discover_child.tsx | 10 ++++++---- .../flyout/compose_discover/types.ts | 4 ++++ .../compose_discover/use_compose_discover_state.ts | 4 ++++ 3 files changed, 14 insertions(+), 4 deletions(-) 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 index 2a9ba8b1619f2..1a4ce82ce6d7e 100644 --- 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 @@ -67,8 +67,11 @@ export const ComposeDiscoverChild: React.FC = ({ useEsqlAutocomplete(services); const [localQuery, setLocalQuery] = useState(state.fullQuery); - const [dateStart, setDateStart] = useState('now-15m'); - const [dateEnd, setDateEnd] = useState('now'); + // Date range persists in the reducer so it's remembered across Sandbox open/close. + // It is intentionally not connected to schedule.lookback in FormValues — it's a + // preview window for testing the query, not a rule configuration field. + const dateStart = state.sandboxDateStart; + const dateEnd = state.sandboxDateEnd; const timeRange = useMemo(() => ({ from: dateStart, to: dateEnd }), [dateStart, dateEnd]); @@ -211,8 +214,7 @@ export const ComposeDiscoverChild: React.FC = ({ start={dateStart} end={dateEnd} onTimeChange={({ start, end }) => { - setDateStart(start); - setDateEnd(end); + dispatch({ type: 'SET_SANDBOX_DATE_RANGE', start, end }); }} showUpdateButton={false} compressed 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 index f05bc50a2471e..49b99894451b2 100644 --- 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 @@ -37,6 +37,9 @@ export interface ComposeDiscoverState { yamlMode: boolean; childOpen: boolean; queryCommitted: boolean; + /** Date range for the Discover Sandbox preview window — persists across open/close */ + sandboxDateStart: string; + sandboxDateEnd: string; } export type ComposeDiscoverAction = @@ -57,6 +60,7 @@ export type ComposeDiscoverAction = | { type: 'GO_NEXT' } | { type: 'GO_BACK' } | { type: 'SET_NOTIFICATIONS_ENABLED'; enabled: boolean } + | { type: 'SET_SANDBOX_DATE_RANGE'; start: string; end: string } | { type: 'OPEN_CHILD' } | { type: 'OPEN_CHILD_FOR_STEP'; step: number } | { type: 'CLOSE_CHILD' } 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 index 90317b37c8025..46a866f304f45 100644 --- 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 @@ -36,6 +36,8 @@ const createInitialState = (mode: ComposeDiscoverMode): ComposeDiscoverState => yamlMode: false, childOpen: mode === 'create', queryCommitted: mode === 'edit', + sandboxDateStart: 'now-15m', + sandboxDateEnd: 'now', }); /** @@ -104,6 +106,8 @@ function reducer( const prevStep = Math.max(state.step - 1, 0); return { ...state, step: prevStep, childOpen: false }; } + case 'SET_SANDBOX_DATE_RANGE': + return { ...state, sandboxDateStart: action.start, sandboxDateEnd: action.end }; case 'OPEN_CHILD': return { ...state, childOpen: true }; case 'OPEN_CHILD_FOR_STEP': From 31f592a1a79f554b6a725669ee0c2705640d112a Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 22:18:21 -0400 Subject: [PATCH 15/52] =?UTF-8?q?Remove=20ComposeDiscoverTabs=20=E2=80=94?= =?UTF-8?q?=20dead=20code=20in=20this=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ComposeDiscoverTabs was copied in from the prototype branch where it handled the Base/Alert/Recovery tab layout in the Discover Sandbox. In this PR, the Sandbox renders a single CodeEditor directly (tab support is deferred to the custom recovery follow-up PR). The component was never imported or rendered anywhere — it was pure dead code carrying ~100 lines and unnecessary props (tabConfig, hideTabBar, services) that served no function. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover_tabs.tsx | 52 ------------------- 1 file changed, 52 deletions(-) delete mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_tabs.tsx 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 deleted file mode 100644 index fb7dd93b9fd8c..0000000000000 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_tabs.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo } from 'react'; -import { CodeEditor } from '@kbn/code-editor'; -import { ESQL_LANG_ID } from '@kbn/monaco'; -import type { ComposeDiscoverState, ComposeDiscoverAction, SandboxTabConfig } from './types'; -import type { RuleFormServices } from '../../form/contexts/rule_form_context'; - -interface ComposeDiscoverTabsProps { - state: ComposeDiscoverState; - dispatch: React.Dispatch; - /** Controls which tabs are shown. Always `{ type: 'single' }` for now. */ - tabConfig: SandboxTabConfig; - /** When true, renders only the editor content — tab bar is rendered separately in the header. */ - hideTabBar?: boolean; - /** Required for future split-query autocomplete (alert/recovery block editors). */ - services: RuleFormServices; -} - -/** - * Renders the single ES|QL editor for the Discover Sandbox. - * Tab variants (Base/Alert/Recovery) are added in the custom recovery follow-up PR. - */ -export const ComposeDiscoverTabs: React.FC = ({ - state, - dispatch, -}) => { - const editorOptions = useMemo( - () => ({ - minimap: { enabled: false }, - automaticLayout: true, - scrollBeyondLastLine: false, - fontSize: 13, - }), - [] - ); - - return ( - dispatch({ type: 'SET_FULL_QUERY', query: val })} - height="100%" - options={editorOptions} - /> - ); -}; From d7e28394f0f6343c372481dcb21d6e29dc105bc4 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 22:22:17 -0400 Subject: [PATCH 16/52] Remove dead tabConfig prop from ComposeDiscoverChild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tabConfig (SandboxTabConfig) was in ComposeDiscoverChildProps and passed from the flyout, but was never destructured or used anywhere in the component body. It was a remnant of the prototype where tabs existed — in this PR the Sandbox always renders a single CodeEditor with no tabs. Also removes the now-unused getSandboxTabConfig import from the flyout and the tabConfig local variable that computed it. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../flyout/compose_discover/compose_discover_child.tsx | 4 +--- .../flyout/compose_discover/compose_discover_flyout.tsx | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) 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 index 1a4ce82ce6d7e..5e652f2a023b4 100644 --- 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 @@ -34,7 +34,7 @@ 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 type { ComposeDiscoverState, ComposeDiscoverAction } from './types'; import { useQueryExecution } from './use_query_execution'; import { useEsqlAutocomplete } from './use_esql_providers'; import { ComposeDiscoverChart } from './compose_discover_chart'; @@ -43,8 +43,6 @@ interface ComposeDiscoverChildProps { state: ComposeDiscoverState; dispatch: React.Dispatch; services: RuleFormServices; - /** Always `{ type: 'single' }` for now. */ - tabConfig: SandboxTabConfig; onClose: () => void; } 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 index d9007f95cfb18..ff904a55edb47 100644 --- 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 @@ -28,7 +28,7 @@ import { mapFormValuesToUpdateRequest, } from '../../form/utils/rule_request_mappers'; import type { ComposeDiscoverMode } from './types'; -import { useComposeDiscoverState, getStepTitles, getSandboxTabConfig } from './use_compose_discover_state'; +import { useComposeDiscoverState, getStepTitles } from './use_compose_discover_state'; import { ComposeDiscoverForm } from './compose_discover_form'; import { ComposeDiscoverChild } from './compose_discover_child'; @@ -116,7 +116,6 @@ export const ComposeDiscoverFlyout: React.FC = ({ const stepTitles = getStepTitles(); const isLastStep = uiState.step === stepTitles.length - 1; - const tabConfig = getSandboxTabConfig(uiState); // Sync reducer-owned query fields into RHF whenever the user commits a query // from the Discover Sandbox. This bridges the gap between the two state systems @@ -221,7 +220,6 @@ export const ComposeDiscoverFlyout: React.FC = ({ state={uiState} dispatch={dispatch} services={services} - tabConfig={tabConfig} onClose={() => dispatch({ type: 'CLOSE_CHILD' })} /> )} From dcada5511ff10eced9322a6cb49938f9f998937e Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 22:26:04 -0400 Subject: [PATCH 17/52] Remove dead guessRecoveryBlock export from use_heuristic_split guessRecoveryBlock was exported but never imported or called anywhere in this PR. It was written for the custom recovery feature (where it would auto-suggest a recovery condition by inverting comparison operators in the alert block), but that feature is deferred to a follow-up PR. The implementation also had a correctness issue: it used sequential .replace() calls on plain strings rather than a single-pass substitution, meaning overlapping patterns (e.g. '>=' matched by both '>=' and '>') could produce wrong output. The correct implementation will ship with the custom recovery PR when it can be properly tested end-to-end. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../flyout/compose_discover/use_heuristic_split.ts | 12 ------------ 1 file changed, 12 deletions(-) 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 index 3d245c60f9da0..77bb6cddeb326 100644 --- 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 @@ -86,18 +86,6 @@ export function splitQuery(query: string): SplitResult { 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]); }; From 7df60941ee0a45a371634a5c4f7c14236fd85526 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 22:26:43 -0400 Subject: [PATCH 18/52] Assign split-query completion refs during render, not in useEffect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bare useEffect calls (no dependency array) were syncing baseQuery and search into refs: useEffect(() => { baseQueryRef.current = baseQuery; }); useEffect(() => { searchRef.current = search; }); Bare effects run after every render. Using useEffect here served no purpose beyond adding two effect invocations per render cycle. Refs are mutable objects — assigning to ref.current during render is safe and idiomatic in React for values that need to be readable in closures without causing re-renders. The standard pattern is simply: baseQueryRef.current = baseQuery; searchRef.current = search; This is a minor but meaningful cleanup: fewer effects = less React overhead, and the code correctly conveys that this is a synchronous assignment, not an async side effect. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../flyout/compose_discover/use_split_query_completion.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 index 4856fb147b96e..ca76ee063c0a2 100644 --- 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 @@ -38,11 +38,13 @@ interface UseSplitQueryCompletionParams { * 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. + // Assign refs directly during render — no useEffect needed. Refs are mutable + // and reading/writing them during render is safe. Using useEffect here would + // add two unnecessary effect invocations per render with no benefit. const baseQueryRef = useRef(baseQuery); const searchRef = useRef(search); - useEffect(() => { baseQueryRef.current = baseQuery; }); - useEffect(() => { searchRef.current = search; }); + baseQueryRef.current = baseQuery; + searchRef.current = search; // Store the disposable for cleanup on unmount. const disposableRef = useRef(null); From 7dc3962e1abdb4e9b876b6f24ff5530e79dde460 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 12 May 2026 03:32:20 +0000 Subject: [PATCH 19/52] Changes from node scripts/lint_ts_projects --fix --- .../shared/response-ops/alerting-v2-rule-form/tsconfig.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/tsconfig.json b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/tsconfig.json index 050bdd7d321d7..4002c0f57cfd7 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/tsconfig.json +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/tsconfig.json @@ -38,7 +38,10 @@ "@kbn/field-types", "@kbn/lens-plugin", "@kbn/visualization-utils", - "@kbn/expressions-plugin" + "@kbn/expressions-plugin", + "@kbn/code-editor", + "@kbn/monaco", + "@kbn/esql-language" ] } From ea6e3c30be9b37e8a8f2d78ca9fd4e9840972d34 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 12 May 2026 03:37:21 +0000 Subject: [PATCH 20/52] Changes from node scripts/regenerate_moon_projects.js --update --- .../shared/response-ops/alerting-v2-rule-form/moon.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/moon.yml b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/moon.yml index b25092428007a..fe6fc391a079a 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/moon.yml +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/moon.yml @@ -41,6 +41,9 @@ dependsOn: - '@kbn/lens-plugin' - '@kbn/visualization-utils' - '@kbn/expressions-plugin' + - '@kbn/code-editor' + - '@kbn/monaco' + - '@kbn/esql-language' tags: - shared-browser - package From 8d102a1cdc9cb714349731933c1e3b67ed376bae Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 12 May 2026 03:59:45 +0000 Subject: [PATCH 21/52] Changes from node scripts/eslint_all_files --no-cache --fix --- .../compose_discover_chart.tsx | 8 +- .../compose_discover_child.tsx | 187 +++++++++--------- .../compose_discover_flyout.tsx | 6 +- .../compose_discover_form.tsx | 9 +- .../flyout/compose_discover/types.ts | 4 +- .../use_compose_discover_state.ts | 5 +- .../compose_discover/use_esql_providers.ts | 13 +- .../compose_discover/use_query_execution.ts | 12 +- .../pages/rules_list_page/rules_list_page.tsx | 34 +++- 9 files changed, 147 insertions(+), 131 deletions(-) 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 index d8a12d5e43563..f4608cd446aa4 100644 --- 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 @@ -81,12 +81,8 @@ export const ComposeDiscoverChart: React.FC = ({ query: { esql: query }, }; - const allSuggestions = suggestions( - context, - adHocDataView, - ['lnsDatatable'], - ChartType.Bar - ) ?? []; + const allSuggestions = + suggestions(context, adHocDataView, ['lnsDatatable'], ChartType.Bar) ?? []; const chartSuggestions = allSuggestions.filter( (s) => s.visualizationId && s.visualizationId !== 'lnsDatatable' 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 index 5e652f2a023b4..df19420241565 100644 --- 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 @@ -17,8 +17,6 @@ import { EuiFlexItem, EuiSpacer, EuiLoadingSpinner, - EuiTab, - EuiTabs, EuiText, EuiCallOut, EuiEmptyPrompt, @@ -52,8 +50,7 @@ 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 isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent); const RUN_SHORTCUT_LABEL = isMac ? '⌘⏎' : 'Ctrl+Enter'; export const ComposeDiscoverChild: React.FC = ({ @@ -97,13 +94,22 @@ export const ComposeDiscoverChild: React.FC = ({ 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, - }); + 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) => { @@ -173,12 +179,7 @@ export const ComposeDiscoverChild: React.FC = ({ ); return ( - +

{state.queryCommitted ? 'Edit alert query' : 'Define alert query'}

@@ -223,9 +224,7 @@ export const ComposeDiscoverChild: React.FC = ({ - dispatch({ type: 'SET_TIME_FIELD', timeField: e.target.value }) - } + onChange={(e) => dispatch({ type: 'SET_TIME_FIELD', timeField: e.target.value })} compressed prepend="Time field" data-test-subj="composeDiscoverTimeField" @@ -261,86 +260,86 @@ export const ComposeDiscoverChild: React.FC = ({ {/* ── 4. 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 && ( - <> - + + {!hasRun && ( + Run your query to see results} + body={ +

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

+ } /> + )} - - - + {hasRun && isLoading && ( + - - {totalRowCount.toLocaleString()} {totalRowCount === 1 ? 'result' : 'results'} - + - - - +

{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.toLocaleString()} {totalRowCount === 1 ? 'result' : 'results'} + + + + + + + + )}
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 index ff904a55edb47..b8f9b6dbb31f3 100644 --- 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 @@ -16,7 +16,6 @@ import { EuiFlyoutBody, EuiFlyoutFooter, EuiFlyoutHeader, - EuiSpacer, EuiTitle, } from '@elastic/eui'; import type { RuleFormServices } from '../../form/contexts/rule_form_context'; @@ -96,7 +95,10 @@ export const ComposeDiscoverFlyout: React.FC = ({ tags: mapped.metadata?.tags ?? [], }, timeField: mapped.timeField ?? '@timestamp', - schedule: { every: mapped.schedule?.every ?? '1m', lookback: mapped.schedule?.lookback ?? '5m' }, + schedule: { + every: mapped.schedule?.every ?? '1m', + lookback: mapped.schedule?.lookback ?? '5m', + }, evaluation: { query: { base: mapped.evaluation?.query?.base ?? '' } }, grouping: mapped.grouping, recoveryPolicy: mapped.recoveryPolicy ?? { type: 'no_breach' }, 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 index db5139c9c4213..b776c43e38444 100644 --- 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 @@ -12,8 +12,6 @@ import { EuiCallOut, EuiComboBox, EuiFieldText, - EuiFlexGroup, - EuiFlexItem, EuiFormRow, EuiHorizontalRule, EuiPanel, @@ -24,7 +22,6 @@ import { } from '@elastic/eui'; import type { ComposeDiscoverState, ComposeDiscoverAction } from './types'; import { QuerySummary } from './query_summary'; -import { getStepTitles } from './use_compose_discover_state'; import type { RuleFormServices } from '../../form/contexts/rule_form_context'; import { useDataFields } from '../../form/hooks/use_data_fields'; import { RuleDetailsFieldGroup, RuleExecutionFieldGroup } from '../../form'; @@ -227,7 +224,11 @@ stateTransition: `; } -export const ComposeDiscoverForm: React.FC = ({ state, dispatch, services }) => { +export const ComposeDiscoverForm: React.FC = ({ + state, + dispatch, + services, +}) => { const yamlValue = useMemo(() => buildYaml(state), [state]); if (state.yamlMode) { 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 index 49b99894451b2..e6b60f4ab1ba2 100644 --- 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 @@ -15,7 +15,9 @@ export type DelayMode = 'immediate' | 'breaches' | 'duration'; * Always `{ type: 'single' }` for now — tabs (Base/Alert/Recovery) are added in the * custom recovery follow-up PR. */ -export type SandboxTabConfig = { type: 'single' }; +export interface SandboxTabConfig { + type: 'single'; +} export interface ComposeDiscoverState { mode: ComposeDiscoverMode; 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 index 46a866f304f45..aedebf49dac33 100644 --- 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 @@ -57,10 +57,7 @@ export function getSandboxTabConfig(_state: ComposeDiscoverState): SandboxTabCon return { type: 'single' }; } -function reducer( - state: ComposeDiscoverState, - action: ComposeDiscoverAction -): ComposeDiscoverState { +function reducer(state: ComposeDiscoverState, action: ComposeDiscoverAction): ComposeDiscoverState { switch (action.type) { case 'SET_NAME': return { ...state, name: action.name }; 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 index 0e276e0121ee7..a46325b3da823 100644 --- 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 @@ -51,16 +51,12 @@ export const useEsqlAutocomplete = (services: RuleFormServices) => { const suggestion = ESQLLang.getSuggestionProvider?.(stableCallbacks); if (suggestion) { - disposables.push( - monaco.languages.registerCompletionItemProvider(ESQL_LANG_ID, suggestion) - ); + disposables.push(monaco.languages.registerCompletionItemProvider(ESQL_LANG_ID, suggestion)); } const signature = ESQLLang.getSignatureProvider?.(stableCallbacks); if (signature) { - disposables.push( - monaco.languages.registerSignatureHelpProvider(ESQL_LANG_ID, signature) - ); + disposables.push(monaco.languages.registerSignatureHelpProvider(ESQL_LANG_ID, signature)); } const hover = ESQLLang.getHoverProvider?.(stableCallbacks); @@ -71,8 +67,7 @@ export const useEsqlAutocomplete = (services: RuleFormServices) => { return () => { disposables.forEach((d) => d.dispose()); }; - // Empty deps: register once on mount, clean up on unmount. - // Callbacks stay current via callbacksRef without triggering re-registration. - // eslint-disable-next-line react-hooks/exhaustive-deps + // Empty deps: register once on mount, clean up on unmount. + // Callbacks stay current via callbacksRef without triggering re-registration. }, []); }; 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 index e7a6bdadc4491..fb6a7590a3ee5 100644 --- 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 @@ -110,8 +110,16 @@ export const useQueryExecution = ({ 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 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; 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 0913e012baad1..45629f273d629 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 @@ -97,7 +97,12 @@ export const RulesListPage = () => { setPage(1); }, [debouncedSearch, filter]); - const { data: rulesData, isLoading, isError, error } = useFetchRules({ + const { + data: rulesData, + isLoading, + isError, + error, + } = useFetchRules({ page, perPage, filter, @@ -144,11 +149,7 @@ export const RulesListPage = () => { {/* Primary create button — navigates to the full-page form (existing flow) */} - + { mode={editRule ? 'edit' : 'create'} rule={editRule ?? undefined} ruleId={editRule?.id} - onClose={() => { setFlyoutOpen(false); setEditRule(null); }} + onClose={() => { + setFlyoutOpen(false); + setEditRule(null); + }} services={ruleFormServices} onCreateRule={(payload) => - createRuleMutation.mutate(payload, { onSuccess: () => { setFlyoutOpen(false); } }) + createRuleMutation.mutate(payload, { + onSuccess: () => { + setFlyoutOpen(false); + }, + }) } onUpdateRule={(id, payload) => - updateRuleMutation.mutate({ id, payload }, { onSuccess: () => { setFlyoutOpen(false); setEditRule(null); } }) + updateRuleMutation.mutate( + { id, payload }, + { + onSuccess: () => { + setFlyoutOpen(false); + setEditRule(null); + }, + } + ) } isSaving={createRuleMutation.isLoading || updateRuleMutation.isLoading} /> From 11c5affe0205a068c5d6ad1ff7d3e5fa07e928ef Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 11 May 2026 23:02:01 -0400 Subject: [PATCH 22/52] Complete RHF migration: remove all form-value fields from reducer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All fields that belong to the submitted form (timeField, groupFields, name, tags, schedule, lookback, alertDelayMode, recoveryDelayMode, etc.) are removed from ComposeDiscoverState and ComposeDiscoverAction. The reducer now owns only UI state: step, childOpen, fullQuery, activeTab, yamlMode, queryCommitted, sandboxDateStart/End. Components read and write timeField and grouping directly via useFormContext() — no dispatching, no bridge effects. The two stale useEffect syncs (timeField, groupFields) are removed from compose_discover_flyout.tsx; only the fullQuery sync remains (needed because the Sandbox editor is reducer-owned until Apply is clicked). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover_child.tsx | 37 ++++---- .../compose_discover_flyout.tsx | 17 +--- .../compose_discover_form.tsx | 89 +++++++++---------- .../flyout/compose_discover/types.ts | 35 +++----- .../use_compose_discover_state.ts | 35 -------- .../compose_discover/use_esql_providers.ts | 4 +- .../use_split_query_completion.ts | 4 +- 7 files changed, 74 insertions(+), 147 deletions(-) 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 index df19420241565..00700a51f8cf7 100644 --- 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 @@ -6,6 +6,8 @@ */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import type { FormValues } from '../../form/types'; import { EuiFlyout, EuiFlyoutBody, @@ -73,6 +75,10 @@ export const ComposeDiscoverChild: React.FC = ({ // Single-editor mode: always use localQuery const activeQuery = localQuery; + // Read timeField from RHF — it lives there, not in the UI reducer + const { setValue: setFormValue, watch: watchForm } = useFormContext(); + const timeField = watchForm('timeField') ?? '@timestamp'; + // Only fetch fields when the query has a real index pattern after FROM. const queryForFields = /^\s*FROM\s+[a-zA-Z0-9_.*-]/i.test(activeQuery) ? activeQuery : ''; const { data: fieldMap } = useDataFields({ @@ -94,22 +100,13 @@ export const ComposeDiscoverChild: React.FC = ({ 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, - }); + const { columns, rows, totalRowCount, isLoading, isError, error, run, hasRun, lastExecutedQuery } = + useQueryExecution({ + query: activeQuery, + timeField, + timeRange, + data: services.data, + }); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -192,7 +189,7 @@ export const ComposeDiscoverChild: React.FC = ({ )}
- + {/* ── 1. Search / date picker / time field row — one line ──────── */}
@@ -223,8 +220,8 @@ export const ComposeDiscoverChild: React.FC = ({ dispatch({ type: 'SET_TIME_FIELD', timeField: e.target.value })} + value={timeField} + onChange={(e) => setFormValue('timeField', e.target.value)} compressed prepend="Time field" data-test-subj="composeDiscoverTimeField" @@ -301,7 +298,7 @@ export const ComposeDiscoverChild: React.FC = ({ <> = ({ const stepTitles = getStepTitles(); const isLastStep = uiState.step === stepTitles.length - 1; - // Sync reducer-owned query fields into RHF whenever the user commits a query - // from the Discover Sandbox. This bridges the gap between the two state systems - // so that mapFormValuesToCreateRequest/UpdateRequest receives current values. + // Sync the committed query into RHF whenever the user applies changes from the Sandbox. + // timeField and grouping are written directly to RHF by the form components via useFormContext. useEffect(() => { if (uiState.queryCommitted && uiState.fullQuery) { methods.setValue('evaluation', { query: { base: uiState.fullQuery } }); } }, [uiState.fullQuery, uiState.queryCommitted, methods]); - // Sync time field and group fields from the reducer into RHF as they change. - useEffect(() => { - methods.setValue('timeField', uiState.timeField); - }, [uiState.timeField, methods]); - - useEffect(() => { - methods.setValue( - 'grouping', - uiState.groupFields.length > 0 ? { fields: uiState.groupFields } : undefined - ); - }, [uiState.groupFields, methods]); - const handleSubmit = methods.handleSubmit((values) => { if (isCreate) { onCreateRule(mapFormValuesToCreateRequest(values)); 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 index b776c43e38444..211e80048e7e8 100644 --- 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 @@ -6,6 +6,7 @@ */ import React, { useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; import { CodeEditor } from '@kbn/code-editor'; import { EuiButton, @@ -21,6 +22,7 @@ import { EuiTitle, } from '@elastic/eui'; import type { ComposeDiscoverState, ComposeDiscoverAction } from './types'; +import type { FormValues } from '../../form/types'; import { QuerySummary } from './query_summary'; import type { RuleFormServices } from '../../form/contexts/rule_form_context'; import { useDataFields } from '../../form/hooks/use_data_fields'; @@ -32,12 +34,6 @@ interface ComposeDiscoverFormProps { services: RuleFormServices; } -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' }, -]; - // ── Step content renderers ──────────────────────────────────────────────────── function AlertConditionStep({ @@ -49,8 +45,17 @@ function AlertConditionStep({ dispatch: React.Dispatch; services: RuleFormServices; }) { + const { setValue, watch } = useFormContext(); + const timeField = watch('timeField') ?? '@timestamp'; + const grouping = watch('grouping'); + const groupFields = grouping?.fields ?? []; + + // Only fetch date fields when the query has a committed, valid index pattern + const queryForFields = /^\s*FROM\s+[a-zA-Z0-9_.*-]/i.test(state.fullQuery) && state.queryCommitted + ? state.fullQuery + : ''; const { data: fieldMap } = useDataFields({ - query: state.queryCommitted ? state.fullQuery : '', + query: queryForFields, http: services.http, dataViews: services.dataViews, }); @@ -103,14 +108,13 @@ function AlertConditionStep({ )} - {/* Time field and group fields sit close to query definitions */} dispatch({ type: 'SET_TIME_FIELD', timeField: e.target.value })} + value={timeField} + onChange={(e) => setValue('timeField', e.target.value)} disabled={state.childOpen} data-test-subj="composeDiscoverTimeField" /> @@ -119,30 +123,25 @@ function AlertConditionStep({ ({ label: f }))} + selectedOptions={groupFields.map((f) => ({ label: f }))} onChange={(opts) => - dispatch({ type: 'SET_GROUP_FIELDS', fields: opts.map((o) => o.label) }) + setValue('grouping', opts.length ? { fields: opts.map((o) => o.label) } : undefined) } onCreateOption={(val) => - dispatch({ type: 'SET_GROUP_FIELDS', fields: [...state.groupFields, val] }) + setValue('grouping', { fields: [...groupFields, val] }) } placeholder="Add group fields" data-test-subj="composeDiscoverGroupFields" /> + {/* Schedule and lookback — connected to RHF via useFormContext() internally */} ); } -function DetailsAndArtifactsStep({ - state, - dispatch, -}: { - state: ComposeDiscoverState; - dispatch: React.Dispatch; -}) { +function DetailsAndArtifactsStep() { return ( <> {/* Name, description, tags — connected to RHF via useFormContext() internally */} @@ -175,13 +174,7 @@ function DetailsAndArtifactsStep({ // TODO (#268770): Notifications step — wire workflow selector and notification policy fields // to FormValues once the action policy API integration is in place. -function NotificationsStep({ - state: _state, - dispatch: _dispatch, -}: { - state: ComposeDiscoverState; - dispatch: React.Dispatch; -}) { +function NotificationsStep() { return ( s.split('\n').join('\n '); return `kind: alert metadata: - name: "${state.name}" - tags: [${state.tags.map((t) => `"${t}"`).join(', ')}] + name: "${values.metadata?.name ?? ''}" + tags: [${(values.metadata?.tags ?? []).map((t) => `"${t}"`).join(', ')}] description: "" evaluation: query: base: | ${indent(fullQuery)} grouping: - fields: [${state.groupFields.map((f) => `"${f}"`).join(', ')}] -timeField: "${state.timeField}" + fields: [${(values.grouping?.fields ?? []).map((f) => `"${f}"`).join(', ')}] +timeField: "${values.timeField ?? '@timestamp'}" schedule: - every: "${state.schedule}" - lookback: "${state.lookback}" + every: "${values.schedule?.every ?? '1m'}" + lookback: "${values.schedule?.lookback ?? '5m'}" stateTransition: - alertDelay: { type: "${state.alertDelayMode}" } - recoveryDelay: { type: "${state.recoveryDelayMode}" } + alertDelay: { type: "${values.stateTransitionAlertDelayMode ?? 'immediate'}" } + recoveryDelay: { type: "${values.stateTransitionRecoveryDelayMode ?? 'immediate'}" } `; } -export const ComposeDiscoverForm: React.FC = ({ - state, - dispatch, - services, -}) => { - const yamlValue = useMemo(() => buildYaml(state), [state]); +export const ComposeDiscoverForm: React.FC = ({ state, dispatch, services }) => { + const { watch } = useFormContext(); + const formValues = watch(); + const yamlValue = useMemo( + () => buildYaml(formValues, state.fullQuery), + // eslint-disable-next-line react-hooks/exhaustive-deps + [JSON.stringify(formValues), state.fullQuery] + ); if (state.yamlMode) { return ( @@ -237,8 +231,7 @@ export const ComposeDiscoverForm: React.FC = ({ languageId="yaml" value={yamlValue} onChange={(val) => { - // Two-way sync: update name field as a simple proof-of-concept - // Full YAML→state parsing is out of scope for this prototype + // TODO: Full YAML→form parsing wired in the YAML follow-up PR void val; }} height={600} @@ -253,14 +246,14 @@ export const ComposeDiscoverForm: React.FC = ({ ); } - // Route by step index, not by string title — avoids silent breakage if step titles change + // Route by step index — avoids silent breakage if step titles change switch (state.step) { case 0: return ; case 1: - return ; + return ; case 2: - return ; + return ; default: return null; } 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 index e6b60f4ab1ba2..6b6bf8f64e135 100644 --- 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 @@ -8,7 +8,6 @@ export type ComposeDiscoverMode = 'create' | 'edit'; export type QueryTab = 'base' | 'alert' | 'recovery'; -export type DelayMode = 'immediate' | 'breaches' | 'duration'; /** * Describes which tabs the Discover Sandbox should show. @@ -19,49 +18,35 @@ export interface SandboxTabConfig { type: 'single'; } +/** + * UI-only state for the ComposeDiscover flyout. + * + * This reducer manages navigation and Sandbox state only. + * All form values (name, schedule, query fields, delays, etc.) live in + * useForm() via RHF and are never stored here. + */ export interface ComposeDiscoverState { mode: ComposeDiscoverMode; step: number; + /** The live query in the Sandbox editor — committed to FormValues on "Apply changes". */ fullQuery: string; - notificationsEnabled: boolean; - // Form value fields — migrated to RHF useForm in this PR - 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; - /** Date range for the Discover Sandbox preview window — persists across open/close */ + /** Date range for the Discover Sandbox preview window — persists across open/close. + * Intentionally NOT connected to FormValues.schedule.lookback. */ sandboxDateStart: string; sandboxDateEnd: string; } export type ComposeDiscoverAction = - | { type: 'SET_NAME'; name: string } - | { type: 'SET_TAGS'; tags: string[] } | { type: 'SET_FULL_QUERY'; query: string } | { 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_STEP'; step: number } | { type: 'GO_NEXT' } | { type: 'GO_BACK' } - | { type: 'SET_NOTIFICATIONS_ENABLED'; enabled: boolean } | { type: 'SET_SANDBOX_DATE_RANGE'; start: string; end: string } | { type: 'OPEN_CHILD' } | { type: 'OPEN_CHILD_FOR_STEP'; step: number } 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 index aedebf49dac33..2f2b5111429c0 100644 --- 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 @@ -21,17 +21,6 @@ const createInitialState = (mode: ComposeDiscoverMode): ComposeDiscoverState => mode, step: 0, fullQuery: mode === 'create' ? SAMPLE_QUERY : '', - 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', @@ -53,45 +42,21 @@ export function getStepTitles(): string[] { * Always single for now — tabs (Base/Alert/Recovery) added in custom recovery follow-up. */ export function getSandboxTabConfig(_state: ComposeDiscoverState): SandboxTabConfig { - // Tabs (Base/Alert/Recovery) added in custom recovery follow-up return { type: 'single' }; } 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_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 childOpen: action.enabled ? true : state.childOpen, }; - case 'SET_NOTIFICATIONS_ENABLED': - return { ...state, notificationsEnabled: action.enabled }; case 'SET_STEP': return { ...state, step: action.step }; case 'GO_NEXT': { 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 index a46325b3da823..2ec285d64892e 100644 --- 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 @@ -43,8 +43,8 @@ export const useEsqlAutocomplete = (services: RuleFormServices) => { useEffect(() => { const stableCallbacks: ESQLCallbacks = { - getSources: (...args) => callbacksRef.current.getSources?.(...args), - getColumnsFor: (...args) => callbacksRef.current.getColumnsFor?.(...args), + getSources: (...args) => callbacksRef.current.getSources?.(...args) ?? [], + getColumnsFor: (...args) => callbacksRef.current.getColumnsFor?.(...args) ?? [], }; const disposables: monaco.IDisposable[] = []; 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 index ca76ee063c0a2..b62f8a4ae5c9a 100644 --- 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 @@ -51,8 +51,8 @@ export function useSplitQueryCompletion({ baseQuery, search }: UseSplitQueryComp const onEditorMount = useCallback((editor: monaco.editor.IStandaloneCodeEditor) => { const callbacks: ESQLCallbacks = { - getColumnsFor: async ({ query } = {}) => - getEsqlColumns({ esqlQuery: query ?? '', search: searchRef.current }), + getColumnsFor: async (ctx) => + getEsqlColumns({ esqlQuery: ctx?.query ?? '', search: searchRef.current }), }; disposableRef.current = monaco.languages.registerCompletionItemProvider(ESQL_LANG_ID, { From b35db531aa3143b06100e70c9bb91984e9bb266a Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 12 May 2026 05:51:35 +0000 Subject: [PATCH 23/52] Changes from node scripts/eslint_all_files --no-cache --fix --- .../compose_discover_child.tsx | 25 +++++++++++++------ .../compose_discover_form.tsx | 17 +++++++------ 2 files changed, 27 insertions(+), 15 deletions(-) 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 index 00700a51f8cf7..29ca8c9398673 100644 --- 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 @@ -7,7 +7,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import type { FormValues } from '../../form/types'; import { EuiFlyout, EuiFlyoutBody, @@ -32,6 +31,7 @@ import { } from '@elastic/eui'; import { CodeEditor } from '@kbn/code-editor'; import { ESQL_LANG_ID } from '@kbn/monaco'; +import type { FormValues } from '../../form/types'; import type { RuleFormServices } from '../../form/contexts/rule_form_context'; import { useDataFields } from '../../form/hooks/use_data_fields'; import type { ComposeDiscoverState, ComposeDiscoverAction } from './types'; @@ -100,13 +100,22 @@ export const ComposeDiscoverChild: React.FC = ({ return dateFields.map((name) => ({ value: name, text: name })); }, [fieldMap]); - const { columns, rows, totalRowCount, isLoading, isError, error, run, hasRun, lastExecutedQuery } = - useQueryExecution({ - query: activeQuery, - timeField, - timeRange, - data: services.data, - }); + const { + columns, + rows, + totalRowCount, + isLoading, + isError, + error, + run, + hasRun, + lastExecutedQuery, + } = useQueryExecution({ + query: activeQuery, + timeField, + timeRange, + data: services.data, + }); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { 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 index 211e80048e7e8..024799fae2694 100644 --- 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 @@ -51,9 +51,10 @@ function AlertConditionStep({ const groupFields = grouping?.fields ?? []; // Only fetch date fields when the query has a committed, valid index pattern - const queryForFields = /^\s*FROM\s+[a-zA-Z0-9_.*-]/i.test(state.fullQuery) && state.queryCommitted - ? state.fullQuery - : ''; + const queryForFields = + /^\s*FROM\s+[a-zA-Z0-9_.*-]/i.test(state.fullQuery) && state.queryCommitted + ? state.fullQuery + : ''; const { data: fieldMap } = useDataFields({ query: queryForFields, http: services.http, @@ -127,9 +128,7 @@ function AlertConditionStep({ onChange={(opts) => setValue('grouping', opts.length ? { fields: opts.map((o) => o.label) } : undefined) } - onCreateOption={(val) => - setValue('grouping', { fields: [...groupFields, val] }) - } + onCreateOption={(val) => setValue('grouping', { fields: [...groupFields, val] })} placeholder="Add group fields" data-test-subj="composeDiscoverGroupFields" /> @@ -216,7 +215,11 @@ stateTransition: `; } -export const ComposeDiscoverForm: React.FC = ({ state, dispatch, services }) => { +export const ComposeDiscoverForm: React.FC = ({ + state, + dispatch, + services, +}) => { const { watch } = useFormContext(); const formValues = watch(); const yamlValue = useMemo( From ab2ae8a12a627745492a59e5d743ccea8708390a Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 09:52:00 -0400 Subject: [PATCH 24/52] Restore Create rule button to full path navigation Reverts the href from "#/rules/new" back to the full Kibana app path "/app/management/alertingV2/rules/create", matching the existing test expectation and Kibana's standard navigation pattern. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../public/pages/rules_list_page/rules_list_page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 45629f273d629..1afbbe0aabf4e 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 @@ -149,7 +149,7 @@ export const RulesListPage = () => { {/* Primary create button — navigates to the full-page form (existing flow) */} - + Date: Tue, 12 May 2026 10:06:42 -0400 Subject: [PATCH 25/52] Hoist useEsqlAutocomplete to flyout to prevent provider stacking Moving the hook from ComposeDiscoverChild (which mounts/unmounts on every sandbox open/close) to ComposeDiscoverFlyout (which stays mounted) ensures Monaco providers are registered exactly once per flyout lifetime and disposed on flyout close, instead of accumulating on repeated sandbox open/close cycles. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../flyout/compose_discover/compose_discover_child.tsx | 3 --- .../flyout/compose_discover/compose_discover_flyout.tsx | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) 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 index 29ca8c9398673..f575bb4df1355 100644 --- 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 @@ -36,7 +36,6 @@ import type { RuleFormServices } from '../../form/contexts/rule_form_context'; import { useDataFields } from '../../form/hooks/use_data_fields'; import type { ComposeDiscoverState, ComposeDiscoverAction } from './types'; import { useQueryExecution } from './use_query_execution'; -import { useEsqlAutocomplete } from './use_esql_providers'; import { ComposeDiscoverChart } from './compose_discover_chart'; interface ComposeDiscoverChildProps { @@ -61,8 +60,6 @@ export const ComposeDiscoverChild: React.FC = ({ services, onClose, }) => { - useEsqlAutocomplete(services); - const [localQuery, setLocalQuery] = useState(state.fullQuery); // Date range persists in the reducer so it's remembered across Sandbox open/close. // It is intentionally not connected to schedule.lookback in FormValues — it's a 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 index 5e27567d03a0d..de94e50b976b5 100644 --- 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 @@ -30,6 +30,7 @@ import type { ComposeDiscoverMode } from './types'; import { useComposeDiscoverState, getStepTitles } from './use_compose_discover_state'; import { ComposeDiscoverForm } from './compose_discover_form'; import { ComposeDiscoverChild } from './compose_discover_child'; +import { useEsqlAutocomplete } from './use_esql_providers'; // These hooks live in the plugin, not the package — imported via the plugin's hook layer // when this flyout is rendered in the rules list page. @@ -81,6 +82,9 @@ export const ComposeDiscoverFlyout: React.FC = ({ // ── UI state (step navigation, sandbox open/close, tab selection, etc.) ── const [uiState, dispatch] = useComposeDiscoverState(mode); + // Registered once here so providers persist across Sandbox open/close cycles. + useEsqlAutocomplete(services); + // ── Form values (submitted to the API) ── const defaultValues = useMemo(() => { if (rule) { From 445bafb2b83477c897703aee8d89a8a9e8bbf595 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 10:07:09 -0400 Subject: [PATCH 26/52] Gate step advancement on validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 0→1: Next is disabled until the user commits a query from the Sandbox. A tooltip explains why when the button is inactive. Step 1→2: calls RHF trigger(['metadata.name']) before dispatching GO_NEXT — the user can't advance to Notifications without a rule name. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover_flyout.tsx | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) 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 index de94e50b976b5..ff3d8fa5851d2 100644 --- 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { EuiButton, @@ -17,6 +17,7 @@ import { EuiFlyoutFooter, EuiFlyoutHeader, EuiTitle, + EuiToolTip, } from '@elastic/eui'; import type { RuleFormServices } from '../../form/contexts/rule_form_context'; import { RuleFormProvider } from '../../form/contexts/rule_form_context'; @@ -139,6 +140,17 @@ export const ComposeDiscoverFlyout: React.FC = ({ } }); + const handleNext = useCallback(async () => { + // Step 0: require a committed query before advancing + if (uiState.step === 0 && !uiState.queryCommitted) return; + // Step 1: validate that the rule name has been filled in + if (uiState.step === 1) { + const valid = await methods.trigger(['metadata.name']); + if (!valid) return; + } + dispatch({ type: 'GO_NEXT' }); + }, [uiState.step, uiState.queryCommitted, methods, dispatch]); + return ( @@ -191,16 +203,27 @@ export const ComposeDiscoverFlyout: React.FC = ({ {isCreate ? 'Create rule' : 'Save rule'} ) : ( - dispatch({ type: 'GO_NEXT' })} - data-test-subj="composeDiscoverNext" + - Next - + + Next + + )} From 739e82c521e0dcdb475a4f8edd39674d2ad5b9c9 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 10:07:19 -0400 Subject: [PATCH 27/52] Remove sample query from create mode initial state The editor opens empty so users write their own query from scratch rather than starting from a pre-filled example that may not match their data. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../flyout/compose_discover/use_compose_discover_state.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 index 2f2b5111429c0..73bae3b34d5c0 100644 --- 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 @@ -13,14 +13,10 @@ import type { SandboxTabConfig, } from './types'; -const SAMPLE_QUERY = `FROM logs-* -| STATS count = COUNT(*) BY host.name -| WHERE count > 0`; - const createInitialState = (mode: ComposeDiscoverMode): ComposeDiscoverState => ({ mode, step: 0, - fullQuery: mode === 'create' ? SAMPLE_QUERY : '', + fullQuery: '', activeTab: 'alert', yamlMode: false, childOpen: mode === 'create', From 2c524dd4c0ba4c369df975ec0395dd8e80c2306c Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 10:07:26 -0400 Subject: [PATCH 28/52] Delete Quick Edit Scout tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QuickEditRuleFlyout is no longer the edit path — the pencil icon now opens ComposeDiscoverFlyout. The Scout tests test the old flyout's DOM selectors and page objects (quickEditFlyout, quickEditNameInput, etc.) that no longer apply. Rewriting them for ComposeDiscoverFlyout is deferred to when that flyout's edit flow is fully validated. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../ui/tests/quick_edit_rule.spec.ts | 136 ------------------ 1 file changed, 136 deletions(-) delete mode 100644 x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/ui/tests/quick_edit_rule.spec.ts diff --git a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/ui/tests/quick_edit_rule.spec.ts b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/ui/tests/quick_edit_rule.spec.ts deleted file mode 100644 index 4139fde743ef2..0000000000000 --- a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/ui/tests/quick_edit_rule.spec.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { expect } from '@kbn/scout/ui'; -import { buildCreateRuleData, test } from '../fixtures'; - -test.describe('Quick edit rule flyout', { tag: '@local-stateful-classic' }, () => { - let ruleId: string; - const ruleName = 'scout-quick-edit-rule'; - const updatedName = 'scout-quick-edit-updated'; - - test.beforeAll(async ({ apiServices }) => { - await apiServices.alertingV2.rules.cleanUp(); - const rule = await apiServices.alertingV2.rules.create( - buildCreateRuleData({ metadata: { name: ruleName } }) - ); - ruleId = rule.id; - }); - - test.beforeEach(async ({ browserAuth, pageObjects }) => { - await browserAuth.loginAsAlertingV2Editor(); - await pageObjects.rulesList.goto(); - await expect(pageObjects.rulesList.rulesListTable).toBeVisible({ timeout: 60_000 }); - }); - - test.afterAll(async ({ apiServices }) => { - await apiServices.alertingV2.rules.cleanUp(); - }); - - test('opens the quick edit flyout from the table pencil icon', async ({ page, pageObjects }) => { - await test.step('click the pencil icon in the actions column', async () => { - await pageObjects.rulesList.quickEditButton(ruleId).click(); - }); - - await test.step('quick edit flyout is visible with the rule name populated', async () => { - await expect(pageObjects.rulesList.quickEditFlyout).toBeVisible(); - await expect(pageObjects.rulesList.quickEditNameInput).toHaveValue(ruleName); - }); - - await test.step('quick edit flyout passes a11y checks', async () => { - const { violations } = await page.checkA11y({ - include: ['[data-test-subj="quickEditRuleFlyout"]'], - }); - expect(violations).toHaveLength(0); - }); - - await test.step('close the flyout', async () => { - await pageObjects.rulesList.quickEditCloseButton.click(); - await expect(pageObjects.rulesList.quickEditFlyout).toBeHidden(); - }); - }); - - test('opens the quick edit flyout from the rule summary flyout', async ({ pageObjects }) => { - await test.step('open the rule summary flyout by clicking the expand button', async () => { - await pageObjects.rulesList.expandRuleButton(ruleId).click(); - await expect(pageObjects.rulesList.ruleSummaryFlyout).toBeVisible(); - }); - - await test.step('click the pencil icon in the summary flyout header', async () => { - await pageObjects.rulesList.ruleSummaryQuickEditButton.click(); - }); - - await test.step('summary flyout closes and quick edit flyout opens', async () => { - await expect(pageObjects.rulesList.ruleSummaryFlyout).toBeHidden(); - await expect(pageObjects.rulesList.quickEditFlyout).toBeVisible(); - await expect(pageObjects.rulesList.quickEditNameInput).toHaveValue(ruleName); - }); - - await test.step('close the flyout', async () => { - await pageObjects.rulesList.quickEditCancelButton.click(); - await expect(pageObjects.rulesList.quickEditFlyout).toBeHidden(); - }); - }); - - test('edits a rule name and persists the change', async ({ pageObjects, apiServices }) => { - await test.step('open quick edit and change the rule name', async () => { - await pageObjects.rulesList.quickEditButton(ruleId).click(); - await expect(pageObjects.rulesList.quickEditFlyout).toBeVisible(); - await pageObjects.rulesList.quickEditNameInput.fill(updatedName); - }); - - await test.step('submit the form', async () => { - await pageObjects.rulesList.quickEditSubmitButton.click(); - await expect(pageObjects.rulesList.quickEditFlyout).toBeHidden({ timeout: 30_000 }); - }); - - await test.step('verify the rule name was updated via API', async () => { - await expect - .poll( - async () => { - const rule = await apiServices.alertingV2.rules.get(ruleId); - return rule.metadata?.name; - }, - { timeout: 30_000 } - ) - .toBe(updatedName); - }); - }); - - test('cancel discards changes', async ({ pageObjects, apiServices }) => { - const currentName = (await apiServices.alertingV2.rules.get(ruleId)).metadata?.name; - - await test.step('open quick edit, change the name, then cancel', async () => { - await pageObjects.rulesList.quickEditButton(ruleId).click(); - await expect(pageObjects.rulesList.quickEditFlyout).toBeVisible(); - await pageObjects.rulesList.quickEditNameInput.fill('should-not-persist'); - await pageObjects.rulesList.quickEditCancelButton.click(); - await expect(pageObjects.rulesList.quickEditFlyout).toBeHidden(); - }); - - await test.step('verify the rule name was not changed', async () => { - const rule = await apiServices.alertingV2.rules.get(ruleId); - expect(rule.metadata?.name).toBe(currentName); - }); - }); - - test('opening the summary flyout closes the quick edit flyout', async ({ pageObjects }) => { - await test.step('open the quick edit flyout', async () => { - await pageObjects.rulesList.quickEditButton(ruleId).click(); - await expect(pageObjects.rulesList.quickEditFlyout).toBeVisible(); - }); - - await test.step('click the expand button to open the summary flyout', async () => { - await pageObjects.rulesList.expandRuleButton(ruleId).click(); - }); - - await test.step('quick edit closes and summary flyout opens', async () => { - await expect(pageObjects.rulesList.quickEditFlyout).toBeHidden(); - await expect(pageObjects.rulesList.ruleSummaryFlyout).toBeVisible(); - }); - }); -}); From fb1945bd6a2e3ccf8874ee949e7a1dd7d7a0e2e4 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 12 May 2026 14:49:53 +0000 Subject: [PATCH 29/52] Changes from node scripts/eslint_all_files --no-cache --fix --- .../flyout/compose_discover/compose_discover_flyout.tsx | 3 +-- .../public/pages/rules_list_page/rules_list_page.tsx | 6 +++++- 2 files changed, 6 insertions(+), 3 deletions(-) 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 index ff3d8fa5851d2..ddae27831c14f 100644 --- 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 @@ -215,8 +215,7 @@ export const ComposeDiscoverFlyout: React.FC = ({ iconType="arrowRight" iconSide="right" isDisabled={ - uiState.childOpen || - (uiState.step === 0 && !uiState.queryCommitted) + uiState.childOpen || (uiState.step === 0 && !uiState.queryCommitted) } onClick={handleNext} data-test-subj="composeDiscoverNext" 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 1afbbe0aabf4e..63bb987feabab 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 @@ -149,7 +149,11 @@ export const RulesListPage = () => { {/* Primary create button — navigates to the full-page form (existing flow) */} - + Date: Tue, 12 May 2026 11:41:49 -0400 Subject: [PATCH 30/52] Use basePath.prepend for Create rule href Raw href breaks when Kibana is deployed at a subpath. http.basePath is already available in scope from useService(CoreStart('http')). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../public/pages/rules_list_page/rules_list_page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 63bb987feabab..2cb28c2c5f680 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 @@ -151,7 +151,7 @@ export const RulesListPage = () => { {/* Primary create button — navigates to the full-page form (existing flow) */} Date: Tue, 12 May 2026 11:42:09 -0400 Subject: [PATCH 31/52] =?UTF-8?q?Reorder=20Sandbox=20toolbar:=20time=20fie?= =?UTF-8?q?ld=20=E2=86=92=20date=20picker=20=E2=86=92=20Search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the standard Kibana filter-controls-then-action layout. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover_child.tsx | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) 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 index f575bb4df1355..f2b06757e6365 100644 --- 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 @@ -196,20 +196,18 @@ export const ComposeDiscoverChild: React.FC = ({ - {/* ── 1. Search / date picker / time field row — one line ──────── */} + {/* ── 1. Time field / date picker / Search row — one line ──────── */}
- - - - Search - - + + setFormValue('timeField', e.target.value)} + compressed + prepend="Time field" + data-test-subj="composeDiscoverTimeField" + /> = ({ width="full" /> - - setFormValue('timeField', e.target.value)} - compressed - prepend="Time field" - data-test-subj="composeDiscoverTimeField" - /> + + + + Search + +
From 30c743afb22f9a9bfb1a91999a6fa34fdb4fe9d1 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 11:45:43 -0400 Subject: [PATCH 32/52] Replace two-button create layout with split button Keeps Create rule as the single visible action; flyout entry is tucked into a dropdown arrow to the right so the rules list UI is unchanged until we're ready to fully swap over. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../pages/rules_list_page/rules_list_page.tsx | 94 +++++++++++++------ 1 file changed, 66 insertions(+), 28 deletions(-) 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 2cb28c2c5f680..c84bdfbe16aea 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 @@ -9,12 +9,15 @@ import React, { useEffect, useMemo, useState } from 'react'; import { EuiButton, EuiCallOut, + EuiContextMenu, EuiFieldSearch, EuiFilterGroup, EuiFlexGroup, EuiFlexItem, EuiPageHeader, EuiSpacer, + EuiSplitButton, + useGeneratedHtmlId, type Criteria, } from '@elastic/eui'; import { CoreStart, useService } from '@kbn/core-di-browser'; @@ -24,7 +27,7 @@ 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 { useBoolean, 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'; @@ -64,6 +67,8 @@ export const RulesListPage = () => { useBreadcrumbs('rules_list'); const [flyoutOpen, setFlyoutOpen] = useState(false); + const [isCreateMenuOpen, { off: closeCreateMenu, toggle: toggleCreateMenu }] = useBoolean(false); + const createMenuId = useGeneratedHtmlId({ prefix: 'createRuleMenu' }); const [editRule, setEditRule] = useState(null); const historyKey = useMemo(() => Symbol('ruleAuthoring'), []); const createRuleMutation = useCreateRule(); @@ -146,33 +151,66 @@ export const RulesListPage = () => { /> } rightSideItems={[ - - - {/* Primary create button — navigates to the full-page form (existing flow) */} - - - - - - {/* Flyout create — opens the new stepped flyout experience */} - setFlyoutOpen(true)} - data-test-subj="createRuleFlyoutButton" - > - - - - , + + + application.navigateToUrl( + http.basePath.prepend('/app/management/alertingV2/rules/create') + ) + } + data-test-subj="createRuleButton" + > + + + { + closeCreateMenu(); + setFlyoutOpen(true); + }, + 'data-test-subj': 'createRuleFlyoutButton', + }, + ], + }, + ]} + /> + ), + }} + /> + , ]} /> From f50ed355c14c5ffef5eda416ae2ca2e5389326f0 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 12 May 2026 16:15:11 +0000 Subject: [PATCH 33/52] Changes from node scripts/eslint_all_files --no-cache --fix --- .../public/pages/rules_list_page/rules_list_page.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 c84bdfbe16aea..c747b21db79d9 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 @@ -7,7 +7,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { - EuiButton, EuiCallOut, EuiContextMenu, EuiFieldSearch, @@ -172,10 +171,9 @@ export const RulesListPage = () => { Date: Tue, 12 May 2026 12:29:48 -0400 Subject: [PATCH 34/52] =?UTF-8?q?Remove=20YAML=20mode=20=E2=80=94=20deferr?= =?UTF-8?q?ed=20to=20follow-up=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yamlMode state, SET_YAML_MODE action, buildYaml(), and the yaml CodeEditor branch were prototype code with no UI trigger in this PR. Stripping it all keeps the PR scope clean. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover_form.tsx | 56 +------------------ .../flyout/compose_discover/types.ts | 2 - .../use_compose_discover_state.ts | 7 --- 3 files changed, 1 insertion(+), 64 deletions(-) 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 index 024799fae2694..8021f1d7aed1b 100644 --- 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 @@ -5,9 +5,8 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React from 'react'; import { useFormContext } from 'react-hook-form'; -import { CodeEditor } from '@kbn/code-editor'; import { EuiButton, EuiCallOut, @@ -191,64 +190,11 @@ function NotificationsStep() { // ── Main form component ─────────────────────────────────────────────────────── -function buildYaml(values: FormValues, fullQuery: string): string { - const indent = (s: string) => s.split('\n').join('\n '); - - return `kind: alert -metadata: - name: "${values.metadata?.name ?? ''}" - tags: [${(values.metadata?.tags ?? []).map((t) => `"${t}"`).join(', ')}] - description: "" -evaluation: - query: - base: | - ${indent(fullQuery)} -grouping: - fields: [${(values.grouping?.fields ?? []).map((f) => `"${f}"`).join(', ')}] -timeField: "${values.timeField ?? '@timestamp'}" -schedule: - every: "${values.schedule?.every ?? '1m'}" - lookback: "${values.schedule?.lookback ?? '5m'}" -stateTransition: - alertDelay: { type: "${values.stateTransitionAlertDelayMode ?? 'immediate'}" } - recoveryDelay: { type: "${values.stateTransitionRecoveryDelayMode ?? 'immediate'}" } -`; -} - export const ComposeDiscoverForm: React.FC = ({ state, dispatch, services, }) => { - const { watch } = useFormContext(); - const formValues = watch(); - const yamlValue = useMemo( - () => buildYaml(formValues, state.fullQuery), - // eslint-disable-next-line react-hooks/exhaustive-deps - [JSON.stringify(formValues), state.fullQuery] - ); - - if (state.yamlMode) { - return ( - { - // TODO: Full YAML→form parsing wired in the YAML follow-up PR - void val; - }} - height={600} - options={{ - minimap: { enabled: false }, - automaticLayout: true, - scrollBeyondLastLine: false, - fontSize: 13, - wordWrap: 'on', - }} - /> - ); - } - // Route by step index — avoids silent breakage if step titles change switch (state.step) { case 0: 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 index 6b6bf8f64e135..95f456b9af516 100644 --- 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 @@ -31,7 +31,6 @@ export interface ComposeDiscoverState { /** The live query in the Sandbox editor — committed to FormValues on "Apply changes". */ fullQuery: string; activeTab: QueryTab; - yamlMode: boolean; childOpen: boolean; queryCommitted: boolean; /** Date range for the Discover Sandbox preview window — persists across open/close. @@ -43,7 +42,6 @@ export interface ComposeDiscoverState { export type ComposeDiscoverAction = | { type: 'SET_FULL_QUERY'; query: string } | { type: 'SET_TAB'; tab: QueryTab } - | { type: 'SET_YAML_MODE'; enabled: boolean } | { type: 'SET_STEP'; step: number } | { type: 'GO_NEXT' } | { type: 'GO_BACK' } 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 index 73bae3b34d5c0..86aeea8ac156f 100644 --- 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 @@ -18,7 +18,6 @@ const createInitialState = (mode: ComposeDiscoverMode): ComposeDiscoverState => step: 0, fullQuery: '', activeTab: 'alert', - yamlMode: false, childOpen: mode === 'create', queryCommitted: mode === 'edit', sandboxDateStart: 'now-15m', @@ -47,12 +46,6 @@ function reducer(state: ComposeDiscoverState, action: ComposeDiscoverAction): Co return { ...state, fullQuery: action.query }; case 'SET_TAB': return { ...state, activeTab: action.tab }; - case 'SET_YAML_MODE': - return { - ...state, - yamlMode: action.enabled, - childOpen: action.enabled ? true : state.childOpen, - }; case 'SET_STEP': return { ...state, step: action.step }; case 'GO_NEXT': { From 471ef64fa76e37ae577d2655450112655595b8c4 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 12:34:52 -0400 Subject: [PATCH 35/52] Restore useMemo import after YAML removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AlertConditionStep still uses useMemo for timeFieldOptions — it was accidentally dropped when removing the YAML buildYaml useMemo. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../flyout/compose_discover/compose_discover_form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 8021f1d7aed1b..817612ee530ff 100644 --- 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; import { EuiButton, From daf6a3eac83f9154e8bbd1f28e0a2f682f7f97b4 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 12:53:18 -0400 Subject: [PATCH 36/52] Auto-select timefield when index has no matching date field When the user types a new FROM index and the currently selected time field isn't present in that index's date fields, auto-switch to the first available date field. Prevents confusing query errors when an index uses a different timestamp name (e.g. event.start, timestamp). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover/compose_discover_child.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 index f2b06757e6365..d63af3616519b 100644 --- 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 @@ -97,6 +97,17 @@ export const ComposeDiscoverChild: React.FC = ({ return dateFields.map((name) => ({ value: name, text: name })); }, [fieldMap]); + // When the index changes and the selected time field isn't present in the new + // index's date fields, auto-select the first available one to avoid query errors. + useEffect(() => { + const dateFieldNames = Object.values(fieldMap) + .filter((f) => f.type === 'date') + .map((f) => f.name); + if (dateFieldNames.length > 0 && !dateFieldNames.includes(timeField)) { + setFormValue('timeField', dateFieldNames[0]); + } + }, [fieldMap, timeField, setFormValue]); + const { columns, rows, From d4e4c1d0709036c783bd059666ba7d403561842f Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 12:56:19 -0400 Subject: [PATCH 37/52] Timefield selector shows only real index fields; resets on clear - Drop the hardcoded @timestamp injection: if it's not in the index, it shouldn't appear in the dropdown and cause a confusing query error - Fall back to [@timestamp] only when no index is queried (empty editor) or the index has no date fields at all - On editor clear: reset selected value back to @timestamp convention Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover/compose_discover_child.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) 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 index d63af3616519b..75b2faf0c7d54 100644 --- 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 @@ -90,20 +90,27 @@ export const ComposeDiscoverChild: React.FC = ({ .map((f) => f.name) .sort(); - if (!dateFields.includes('@timestamp')) { - dateFields.unshift('@timestamp'); + // No index queried yet (or index has no date fields) — show @timestamp as the + // conventional default. Never inject it alongside real fields so the selector + // only shows fields that actually exist in the index. + if (dateFields.length === 0) { + return [{ value: '@timestamp', text: '@timestamp' }]; } return dateFields.map((name) => ({ value: name, text: name })); }, [fieldMap]); - // When the index changes and the selected time field isn't present in the new - // index's date fields, auto-select the first available one to avoid query errors. + // Keep the selected time field in sync with what the index actually has. useEffect(() => { const dateFieldNames = Object.values(fieldMap) .filter((f) => f.type === 'date') .map((f) => f.name); - if (dateFieldNames.length > 0 && !dateFieldNames.includes(timeField)) { + + if (dateFieldNames.length === 0) { + // Editor cleared or no date fields — reset to @timestamp convention. + if (timeField !== '@timestamp') setFormValue('timeField', '@timestamp'); + } else if (!dateFieldNames.includes(timeField)) { + // Index changed and selected field isn't present — pick the first real one. setFormValue('timeField', dateFieldNames[0]); } }, [fieldMap, timeField, setFormValue]); From 5c62ca1004ce274f1039764667f9b11480a044f1 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 13:50:30 -0400 Subject: [PATCH 38/52] Update create button test for EuiSplitButton The button now uses application.navigateToUrl() via EuiSplitButton.ActionPrimary onClick instead of a raw href attribute. Update the test assertion accordingly. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../public/pages/rules_list_page/rules_list_page.test.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.test.tsx index 7d21dbd619c86..86ec2bfc9f19f 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.test.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.test.tsx @@ -630,10 +630,9 @@ describe('RulesListPage', () => { renderPage(); - expect(screen.getByTestId('createRuleButton')).toHaveAttribute( - 'href', - '/app/management/alertingV2/rules/create' - ); + fireEvent.click(screen.getByTestId('createRuleButton')); + + expect(mockNavigateToUrl).toHaveBeenCalledWith('/app/management/alertingV2/rules/create'); }); it('shows delete confirmation modal when delete action is clicked', async () => { From 9d50ab472352d3613223017080bf2b37b2c6ab3d Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 13:59:02 -0400 Subject: [PATCH 39/52] =?UTF-8?q?Rename=20fullQuery=20=E2=86=92=20sandbox.?= =?UTF-8?q?query;=20seed=20from=20rule=20in=20edit=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groups pending Sandbox query values under a dedicated `sandbox` object so it's immediately clear these are draft values, not committed to RHF until the user clicks "Apply changes". Extends naturally when tracking mode adds baseQuery/alertBlock/recoveryBlock to the same block. Action renames: SET_FULL_QUERY → SET_SANDBOX_QUERY, COMMIT_CHILD_QUERY → COMMIT_SANDBOX_QUERY. Also seeds sandbox.query from the rule's evaluation.query.base in edit mode so the Alert Condition step shows the existing query summary instead of "No query defined yet" when opening an existing rule. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover_child.tsx | 4 +-- .../compose_discover_flyout.tsx | 12 ++++++--- .../compose_discover_form.tsx | 6 ++--- .../flyout/compose_discover/types.ts | 26 +++++++++++++++--- .../use_compose_discover_state.ts | 27 +++++++++++++------ 5 files changed, 54 insertions(+), 21 deletions(-) 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 index 75b2faf0c7d54..a8a747a30a924 100644 --- 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 @@ -60,7 +60,7 @@ export const ComposeDiscoverChild: React.FC = ({ services, onClose, }) => { - const [localQuery, setLocalQuery] = useState(state.fullQuery); + const [localQuery, setLocalQuery] = useState(state.sandbox.query); // Date range persists in the reducer so it's remembered across Sandbox open/close. // It is intentionally not connected to schedule.lookback in FormValues — it's a // preview window for testing the query, not a rule configuration field. @@ -145,7 +145,7 @@ export const ComposeDiscoverChild: React.FC = ({ }, [run]); const handleDone = useCallback(() => { - dispatch({ type: 'COMMIT_CHILD_QUERY', fullQuery: localQuery }); + dispatch({ type: 'COMMIT_SANDBOX_QUERY', query: localQuery }); onClose(); }, [localQuery, dispatch, onClose]); 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 index ddae27831c14f..1b8f7fbb296dc 100644 --- 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 @@ -81,7 +81,11 @@ export const ComposeDiscoverFlyout: React.FC = ({ isSaving = false, }) => { // ── UI state (step navigation, sandbox open/close, tab selection, etc.) ── - const [uiState, dispatch] = useComposeDiscoverState(mode); + // In edit mode, seed the sandbox draft with the rule's existing query so the + // Alert Condition step shows the current query summary instead of "No query defined". + const initialSandboxQuery = + mode === 'edit' ? (rule ? mapRuleResponseToFormValues(rule).evaluation?.query?.base ?? '' : '') : ''; + const [uiState, dispatch] = useComposeDiscoverState(mode, initialSandboxQuery); // Registered once here so providers persist across Sandbox open/close cycles. useEsqlAutocomplete(services); @@ -127,10 +131,10 @@ export const ComposeDiscoverFlyout: React.FC = ({ // Sync the committed query into RHF whenever the user applies changes from the Sandbox. // timeField and grouping are written directly to RHF by the form components via useFormContext. useEffect(() => { - if (uiState.queryCommitted && uiState.fullQuery) { - methods.setValue('evaluation', { query: { base: uiState.fullQuery } }); + if (uiState.queryCommitted && uiState.sandbox.query) { + methods.setValue('evaluation', { query: { base: uiState.sandbox.query } }); } - }, [uiState.fullQuery, uiState.queryCommitted, methods]); + }, [uiState.sandbox.query, uiState.queryCommitted, methods]); const handleSubmit = methods.handleSubmit((values) => { if (isCreate) { 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 index 817612ee530ff..3b59385daffa0 100644 --- 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 @@ -51,8 +51,8 @@ function AlertConditionStep({ // Only fetch date fields when the query has a committed, valid index pattern const queryForFields = - /^\s*FROM\s+[a-zA-Z0-9_.*-]/i.test(state.fullQuery) && state.queryCommitted - ? state.fullQuery + /^\s*FROM\s+[a-zA-Z0-9_.*-]/i.test(state.sandbox.query) && state.queryCommitted + ? state.sandbox.query : ''; const { data: fieldMap } = useDataFields({ query: queryForFields, @@ -94,7 +94,7 @@ function AlertConditionStep({ ) : ( <> - + ({ +const createInitialState = ( + mode: ComposeDiscoverMode, + initialSandboxQuery = '' +): ComposeDiscoverState => ({ mode, step: 0, - fullQuery: '', + sandbox: { query: initialSandboxQuery }, activeTab: 'alert', childOpen: mode === 'create', queryCommitted: mode === 'edit', @@ -42,8 +45,8 @@ export function getSandboxTabConfig(_state: ComposeDiscoverState): SandboxTabCon function reducer(state: ComposeDiscoverState, action: ComposeDiscoverAction): ComposeDiscoverState { switch (action.type) { - case 'SET_FULL_QUERY': - return { ...state, fullQuery: action.query }; + case 'SET_SANDBOX_QUERY': + return { ...state, sandbox: { ...state.sandbox, query: action.query } }; case 'SET_TAB': return { ...state, activeTab: action.tab }; case 'SET_STEP': @@ -65,13 +68,21 @@ function reducer(state: ComposeDiscoverState, action: ComposeDiscoverAction): Co return { ...state, step: action.step, childOpen: true }; case 'CLOSE_CHILD': return { ...state, childOpen: false }; - case 'COMMIT_CHILD_QUERY': - return { ...state, fullQuery: action.fullQuery, childOpen: false, queryCommitted: true }; + case 'COMMIT_SANDBOX_QUERY': + return { + ...state, + sandbox: { ...state.sandbox, query: action.query }, + childOpen: false, + queryCommitted: true, + }; default: return state; } } -export const useComposeDiscoverState = (mode: ComposeDiscoverMode = 'create') => { - return useReducer(reducer, mode, createInitialState); +export const useComposeDiscoverState = ( + mode: ComposeDiscoverMode = 'create', + initialSandboxQuery = '' +) => { + return useReducer(reducer, undefined, () => createInitialState(mode, initialSandboxQuery)); }; From 6ea845775aa0bcc1104e1ff43284f4f5825c15f8 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 14:01:39 -0400 Subject: [PATCH 40/52] Guard metadata.tags against undefined in rule mapper When the user hasn't interacted with the tags field, getValues() can return undefined. The API rejects undefined tags; default to []. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../alerting-v2-rule-form/form/utils/rule_request_mappers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/rule_request_mappers.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/rule_request_mappers.ts index e5ae99b5b392c..2b0dc913921c9 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/rule_request_mappers.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/rule_request_mappers.ts @@ -45,7 +45,7 @@ const mapMetadata = (metadata: FormValues['metadata']) => ({ name: metadata.name, description: metadata.description, owner: metadata.owner, - tags: metadata.tags, + tags: metadata.tags ?? [], }); const mapSchedule = (schedule: FormValues['schedule']) => ({ From e0470e7c3c965b44b28c8f738ddf02a94df8ddc4 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 14:05:12 -0400 Subject: [PATCH 41/52] Fix injectTimeFilter for single-line queries Newline-based splitting puts the time filter after STATS on single-line queries (e.g. FROM x | STATS...), making @timestamp out of scope. Split on the first pipe instead so the WHERE always lands immediately after the FROM clause regardless of formatting. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover/use_query_execution.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 index fb6a7590a3ee5..fb0deac679f69 100644 --- 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 @@ -52,13 +52,17 @@ 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; - + // Split on the first pipe rather than on newlines so that single-line queries + // like `FROM logs-* | STATS count() BY host` don't end up with the time filter + // after STATS (where @timestamp is no longer in scope). + const fromMatch = trimmed.match(/^(FROM\s[^|]+)/i); + if (!fromMatch) return trimmed; + + const fromClause = fromMatch[1].trimEnd(); + const rest = trimmed.slice(fromMatch[1].length).trimStart(); const whereClause = `| WHERE ${timeField} >= ?_tstart AND ${timeField} <= ?_tend`; - lines.splice(fromIdx + 1, 0, whereClause); - return lines.join('\n'); + + return rest ? `${fromClause}\n${whereClause}\n${rest}` : `${fromClause}\n${whereClause}`; } export const useQueryExecution = ({ From d0e117f677e0973df75aec0b2e27cc2c8173ad0d Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 14:10:28 -0400 Subject: [PATCH 42/52] Remove duplicate result count above data grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit totalRowCount was shown in two places — below the editor and above the grid. Keep only the footer below the editor; revisit placement to match Discover's layout in a follow-up. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../flyout/compose_discover/compose_discover_child.tsx | 9 --------- 1 file changed, 9 deletions(-) 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 index a8a747a30a924..be249e09f57c4 100644 --- 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 @@ -331,15 +331,6 @@ export const ComposeDiscoverChild: React.FC = ({ - - - - {totalRowCount.toLocaleString()} {totalRowCount === 1 ? 'result' : 'results'} - - - - - Date: Tue, 12 May 2026 14:16:37 -0400 Subject: [PATCH 43/52] Inline schedule and lookback fields into Alert Condition step Remove the RuleExecutionFieldGroup wrapper (which added a collapsible 'Rule execution' section header) and render ScheduleField and LookbackWindowField directly alongside Time field and Group fields with consistent EuiSpacer spacing. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../flyout/compose_discover/compose_discover_form.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 index 3b59385daffa0..62a11ad73d0b8 100644 --- 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 @@ -25,7 +25,9 @@ import type { FormValues } from '../../form/types'; import { QuerySummary } from './query_summary'; import type { RuleFormServices } from '../../form/contexts/rule_form_context'; import { useDataFields } from '../../form/hooks/use_data_fields'; -import { RuleDetailsFieldGroup, RuleExecutionFieldGroup } from '../../form'; +import { RuleDetailsFieldGroup } from '../../form'; +import { ScheduleField } from '../../form/fields/schedule_field'; +import { LookbackWindowField } from '../../form/fields/lookback_window_field'; interface ComposeDiscoverFormProps { state: ComposeDiscoverState; @@ -133,8 +135,10 @@ function AlertConditionStep({ />
- {/* Schedule and lookback — connected to RHF via useFormContext() internally */} - + + + + ); } From 78825f55200d440b4ce1ad49f1fd789403f3dfb3 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 14:20:21 -0400 Subject: [PATCH 44/52] Import ESQL_LANG_ID/ESQLLang/monaco from @kbn/code-editor @kbn/code-editor is the public interface that re-exports from @kbn/monaco. Consumers should import through it rather than reaching directly into @kbn/monaco. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../flyout/compose_discover/compose_discover_child.tsx | 2 +- .../flyout/compose_discover/query_summary.tsx | 2 +- .../flyout/compose_discover/use_esql_providers.ts | 2 +- .../flyout/compose_discover/use_split_query_completion.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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 index be249e09f57c4..9d846fb85b3f0 100644 --- 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 @@ -30,7 +30,7 @@ import { type EuiDataGridCellValueElementProps, } from '@elastic/eui'; import { CodeEditor } from '@kbn/code-editor'; -import { ESQL_LANG_ID } from '@kbn/monaco'; +import { ESQL_LANG_ID } from '@kbn/code-editor'; import type { FormValues } from '../../form/types'; import type { RuleFormServices } from '../../form/contexts/rule_form_context'; import { useDataFields } from '../../form/hooks/use_data_fields'; 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 index db9baf5409e81..765df2f5ada9b 100644 --- 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 @@ -8,7 +8,7 @@ import React from 'react'; import { EuiPanel, EuiText } from '@elastic/eui'; import { CodeEditor } from '@kbn/code-editor'; -import { ESQL_LANG_ID } from '@kbn/monaco'; +import { ESQL_LANG_ID } from '@kbn/code-editor'; interface QuerySummaryProps { query: string; 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 index 2ec285d64892e..f3d5f84f975e9 100644 --- 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 @@ -6,7 +6,7 @@ */ import { useEffect, useRef } from 'react'; -import { ESQLLang, ESQL_LANG_ID, monaco } from '@kbn/monaco'; +import { ESQLLang, ESQL_LANG_ID, monaco } from '@kbn/code-editor'; import type { ESQLCallbacks } from '@kbn/esql-types'; import { useEsqlCallbacks } from '../../form/hooks/use_esql_callbacks'; import type { RuleFormServices } from '../../form/contexts/rule_form_context'; 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 index b62f8a4ae5c9a..e553a6d0a24f5 100644 --- 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 @@ -8,7 +8,7 @@ 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 { ESQL_LANG_ID, monaco } from '@kbn/code-editor'; import type { ESQLCallbacks } from '@kbn/esql-types'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; From c9b5d0c8bb98c837cba0d0b1527fe89383135005 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 14:28:46 -0400 Subject: [PATCH 45/52] Pull services from context in chart and child components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ComposeDiscoverChart and ComposeDiscoverChild now call useRuleFormServices() directly instead of receiving services (or lens/dataViews) as props. RuleFormProvider is already in the tree above both — no need to thread the bag down through the call chain. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../flyout/compose_discover/compose_discover_chart.tsx | 9 +++------ .../flyout/compose_discover/compose_discover_child.tsx | 7 ++----- .../flyout/compose_discover/compose_discover_flyout.tsx | 1 - 3 files changed, 5 insertions(+), 12 deletions(-) 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 index f4608cd446aa4..cf6cc4206f8aa 100644 --- 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 @@ -9,10 +9,10 @@ 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 { TypedLensByValueInput } 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 { useRuleFormServices } from '../../form/contexts/rule_form_context'; import type { QueryColumn } from './use_query_execution'; const CHART_HEIGHT = 180; @@ -22,8 +22,6 @@ interface ComposeDiscoverChartProps { timeField: string; timeRange: { from: string; to: string }; columns: QueryColumn[]; - lens: LensPublicStart; - dataViews: DataViewsPublicPluginStart; } const toDatatableColumns = (columns: QueryColumn[]): DatatableColumn[] => @@ -41,9 +39,8 @@ export const ComposeDiscoverChart: React.FC = ({ timeField, timeRange, columns, - lens, - dataViews, }) => { + const { lens, dataViews } = useRuleFormServices(); const [lensAttributes, setLensAttributes] = useState< TypedLensByValueInput['attributes'] | undefined >(undefined); 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 index 9d846fb85b3f0..8cefb06e2d772 100644 --- 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 @@ -32,7 +32,7 @@ import { import { CodeEditor } from '@kbn/code-editor'; import { ESQL_LANG_ID } from '@kbn/code-editor'; import type { FormValues } from '../../form/types'; -import type { RuleFormServices } from '../../form/contexts/rule_form_context'; +import { useRuleFormServices } from '../../form/contexts/rule_form_context'; import { useDataFields } from '../../form/hooks/use_data_fields'; import type { ComposeDiscoverState, ComposeDiscoverAction } from './types'; import { useQueryExecution } from './use_query_execution'; @@ -41,7 +41,6 @@ import { ComposeDiscoverChart } from './compose_discover_chart'; interface ComposeDiscoverChildProps { state: ComposeDiscoverState; dispatch: React.Dispatch; - services: RuleFormServices; onClose: () => void; } @@ -57,9 +56,9 @@ const RUN_SHORTCUT_LABEL = isMac ? '⌘⏎' : 'Ctrl+Enter'; export const ComposeDiscoverChild: React.FC = ({ state, dispatch, - services, onClose, }) => { + const services = useRuleFormServices(); const [localQuery, setLocalQuery] = useState(state.sandbox.query); // Date range persists in the reducer so it's remembered across Sandbox open/close. // It is intentionally not connected to schedule.lookback in FormValues — it's a @@ -325,8 +324,6 @@ export const ComposeDiscoverChild: React.FC = ({ timeField={timeField} timeRange={timeRange} columns={columns} - lens={services.lens} - dataViews={services.dataViews} /> 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 index 1b8f7fbb296dc..d00a9c988eb47 100644 --- 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 @@ -238,7 +238,6 @@ export const ComposeDiscoverFlyout: React.FC = ({ dispatch({ type: 'CLOSE_CHILD' })} /> )} From 66f17ed07fe1a841075b3bd7c681b8150376d545 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 14:38:12 -0400 Subject: [PATCH 46/52] Remove MAX_ROWS client-side slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ES|QL already caps results at 10,000 server-side. Slicing again in the browser was inconsistent (footer showed ES total, grid showed only 500) and wasteful (all rows allocated then discarded). EuiDataGrid paginates over whatever ES returns — no client-side cap needed. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../flyout/compose_discover/use_query_execution.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index fb0deac679f69..9df8dcc25da68 100644 --- 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 @@ -11,8 +11,6 @@ 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; } @@ -160,7 +158,7 @@ export const useQueryExecution = ({ }); return record; }); - return { rows: allRows.slice(0, MAX_ROWS), totalRowCount: allRows.length }; + return { rows: allRows, totalRowCount: allRows.length }; }, [response?.columns, response?.values]); const errorMessage = isError && error instanceof Error ? error.message : null; From c4b2d2ebaf40e2b1f89d027dd4386434cf9fb8bf Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 14:43:24 -0400 Subject: [PATCH 47/52] Guard suggest() in split query completion provider Monaco swallows unhandled rejections from provideCompletionItems already, but explicit try/catch prevents unhandled promise rejection warnings and makes the fallback intentional: return no suggestions on failure rather than letting the rejection propagate silently. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover/use_split_query_completion.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 index e553a6d0a24f5..a2b3d4e97c29c 100644 --- 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 @@ -71,7 +71,15 @@ export function useSplitQueryCompletion({ baseQuery, search }: UseSplitQueryComp // 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); + let rawSuggestions: Awaited>; + try { + rawSuggestions = await suggest(fullQuery, fullQueryOffset, callbacks); + } catch { + // Monaco already swallows unhandled rejections from provideCompletionItems, + // but wrapping here prevents unhandled promise rejection warnings and makes + // the degradation explicit: autocomplete silently returns nothing on failure. + return { suggestions: [] }; + } // 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. From 68c6a0d02074d46a29838877bddea83c7aece4c3 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 15:15:32 -0400 Subject: [PATCH 48/52] Group fields: output column options + auto-populate from STATS BY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4 — options: uses getEsqlColumns (| LIMIT 0) to derive the output columns of the committed pipeline query. Works in edit mode (query seeded on mount) and create mode, with no dependency on the sandbox flyout having been opened or run. #3 — auto-populate: on first query commit, parses the AST with Parser from @elastic/esql to extract BY column names from the last STATS command and pre-fills grouping.fields if currently empty. Does not override existing grouping in edit mode. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover_form.tsx | 53 ++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) 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 index 62a11ad73d0b8..a77a71d2b2c9a 100644 --- 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 @@ -5,8 +5,11 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { useFormContext } from 'react-hook-form'; +import { Parser, isColumn } from '@elastic/esql'; +import { useQuery } from '@kbn/react-query'; +import { getEsqlColumns } from '@kbn/esql-utils'; import { EuiButton, EuiCallOut, @@ -51,13 +54,13 @@ function AlertConditionStep({ const grouping = watch('grouping'); const groupFields = grouping?.fields ?? []; - // Only fetch date fields when the query has a committed, valid index pattern - const queryForFields = - /^\s*FROM\s+[a-zA-Z0-9_.*-]/i.test(state.sandbox.query) && state.queryCommitted - ? state.sandbox.query - : ''; + const hasValidQuery = + /^\s*FROM\s+[a-zA-Z0-9_.*-]/i.test(state.sandbox.query) && state.queryCommitted; + const committedQuery = hasValidQuery ? state.sandbox.query : ''; + + // Source index fields → date-type options for the time field selector const { data: fieldMap } = useDataFields({ - query: queryForFields, + query: committedQuery, http: services.http, dataViews: services.dataViews, }); @@ -70,6 +73,41 @@ function AlertConditionStep({ return dateFields.map((name) => ({ value: name, text: name })); }, [fieldMap]); + // Output columns of the full pipeline → options for the group fields selector. + // Uses | LIMIT 0 so no data is transferred — only the output schema is returned. + // Works in edit mode (query seeded on mount) without requiring the sandbox to be opened. + const { data: outputColumns = [] } = useQuery({ + queryKey: ['composeDiscoverOutputColumns', committedQuery], + queryFn: async () => { + const cols = await getEsqlColumns({ esqlQuery: committedQuery, search: services.data.search.search }); + return cols.map((c) => c.name); + }, + enabled: Boolean(committedQuery), + refetchOnWindowFocus: false, + keepPreviousData: true, + }); + + // Auto-populate group fields from the STATS BY clause when a query is first committed + // and the user hasn't already set any group fields. + const autoPopulatedForRef = useRef(null); + useEffect(() => { + if (!state.queryCommitted || groupFields.length > 0 || !state.sandbox.query) return; + if (autoPopulatedForRef.current === state.sandbox.query) return; + autoPopulatedForRef.current = state.sandbox.query; + try { + const { root } = Parser.parse(state.sandbox.query); + const statsCmd = [...root.commands].reverse().find((c) => c.name === 'stats'); + type AstNode = { type: string; name: string; args?: unknown[] }; + const byOption = (statsCmd?.args as AstNode[] | undefined)?.find( + (a) => a.type === 'option' && a.name === 'by' + ); + const byFields = (byOption?.args ?? []).filter(isColumn).map((a) => a.name); + if (byFields.length > 0) setValue('grouping', { fields: byFields }); + } catch { + // Non-parseable query — skip auto-populate + } + }, [state.queryCommitted, state.sandbox.query, groupFields.length, setValue]); + return ( <> @@ -125,6 +163,7 @@ function AlertConditionStep({ ({ label: name }))} selectedOptions={groupFields.map((f) => ({ label: f }))} onChange={(opts) => setValue('grouping', opts.length ? { fields: opts.map((o) => o.label) } : undefined) From 10869645c10c3410b0316e62dc0dd3ee0bbe3ed0 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 12 May 2026 15:28:37 -0400 Subject: [PATCH 49/52] Use AST parser for injectTimeFilter instead of regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parser.parse() from @elastic/esql gives us the exact character position where the FROM command ends via fromCmd.location.max, so the WHERE is inserted in the correct place regardless of pipes inside index names or string literals — the same reason we're moving away from the naive pipe scan in use_heuristic_split.ts. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../compose_discover/use_query_execution.ts | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) 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 index 9df8dcc25da68..f047861bc0894 100644 --- 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 @@ -8,6 +8,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@kbn/react-query'; import { getESQLResults } from '@kbn/esql-utils'; +import { Parser } from '@elastic/esql'; import type { EuiDataGridColumn } from '@elastic/eui'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; @@ -50,17 +51,20 @@ function injectTimeFilter(query: string, timeField: string): string { const trimmed = query.trim(); if (!trimmed) return trimmed; - // Split on the first pipe rather than on newlines so that single-line queries - // like `FROM logs-* | STATS count() BY host` don't end up with the time filter - // after STATS (where @timestamp is no longer in scope). - const fromMatch = trimmed.match(/^(FROM\s[^|]+)/i); - if (!fromMatch) return trimmed; - - const fromClause = fromMatch[1].trimEnd(); - const rest = trimmed.slice(fromMatch[1].length).trimStart(); - const whereClause = `| WHERE ${timeField} >= ?_tstart AND ${timeField} <= ?_tend`; - - return rest ? `${fromClause}\n${whereClause}\n${rest}` : `${fromClause}\n${whereClause}`; + try { + const { root } = Parser.parse(trimmed); + const fromCmd = root.commands.find((c) => c.name === 'from' || c.name === 'ts'); + if (!fromCmd) return trimmed; + + // Insert the WHERE clause immediately after the FROM command using its AST location. + // This is immune to pipes inside index names or string literals, unlike regex-based splitting. + const before = trimmed.slice(0, fromCmd.location.max + 1).trimEnd(); + const after = trimmed.slice(fromCmd.location.max + 1).trimStart(); + const whereClause = `| WHERE ${timeField} >= ?_tstart AND ${timeField} <= ?_tend`; + return after ? `${before}\n${whereClause}\n${after}` : `${before}\n${whereClause}`; + } catch { + return trimmed; + } } export const useQueryExecution = ({ From fb322bedbfad235afb489530d8624ebaadf6e72a Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 12 May 2026 19:39:26 +0000 Subject: [PATCH 50/52] Changes from node scripts/lint_ts_projects --fix --- .../shared/response-ops/alerting-v2-rule-form/tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/tsconfig.json b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/tsconfig.json index 4002c0f57cfd7..cfd2b544bd6a1 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/tsconfig.json +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/tsconfig.json @@ -40,7 +40,6 @@ "@kbn/visualization-utils", "@kbn/expressions-plugin", "@kbn/code-editor", - "@kbn/monaco", "@kbn/esql-language" ] } From eb0ab9c8a43c47c819fbf4ebee5fe68b322103b8 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 12 May 2026 19:45:33 +0000 Subject: [PATCH 51/52] Changes from node scripts/regenerate_moon_projects.js --update --- .../packages/shared/response-ops/alerting-v2-rule-form/moon.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/moon.yml b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/moon.yml index fe6fc391a079a..2b14faca08d9c 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/moon.yml +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/moon.yml @@ -42,7 +42,6 @@ dependsOn: - '@kbn/visualization-utils' - '@kbn/expressions-plugin' - '@kbn/code-editor' - - '@kbn/monaco' - '@kbn/esql-language' tags: - shared-browser From 5bd7f2abc9a547f70a557b8d2a0b909e53096271 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 12 May 2026 20:19:48 +0000 Subject: [PATCH 52/52] Changes from node scripts/eslint_all_files --no-cache --fix --- .../compose_discover/compose_discover_flyout.tsx | 6 +++++- .../flyout/compose_discover/compose_discover_form.tsx | 11 +++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) 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 index d00a9c988eb47..c8420403f53a6 100644 --- 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 @@ -84,7 +84,11 @@ export const ComposeDiscoverFlyout: React.FC = ({ // In edit mode, seed the sandbox draft with the rule's existing query so the // Alert Condition step shows the current query summary instead of "No query defined". const initialSandboxQuery = - mode === 'edit' ? (rule ? mapRuleResponseToFormValues(rule).evaluation?.query?.base ?? '' : '') : ''; + mode === 'edit' + ? rule + ? mapRuleResponseToFormValues(rule).evaluation?.query?.base ?? '' + : '' + : ''; const [uiState, dispatch] = useComposeDiscoverState(mode, initialSandboxQuery); // Registered once here so providers persist across Sandbox open/close cycles. 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 index a77a71d2b2c9a..bbc59972838a5 100644 --- 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 @@ -79,7 +79,10 @@ function AlertConditionStep({ const { data: outputColumns = [] } = useQuery({ queryKey: ['composeDiscoverOutputColumns', committedQuery], queryFn: async () => { - const cols = await getEsqlColumns({ esqlQuery: committedQuery, search: services.data.search.search }); + const cols = await getEsqlColumns({ + esqlQuery: committedQuery, + search: services.data.search.search, + }); return cols.map((c) => c.name); }, enabled: Boolean(committedQuery), @@ -97,7 +100,11 @@ function AlertConditionStep({ try { const { root } = Parser.parse(state.sandbox.query); const statsCmd = [...root.commands].reverse().find((c) => c.name === 'stats'); - type AstNode = { type: string; name: string; args?: unknown[] }; + interface AstNode { + type: string; + name: string; + args?: unknown[]; + } const byOption = (statsCmd?.args as AstNode[] | undefined)?.find( (a) => a.type === 'option' && a.name === 'by' );