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..cf6cc4206f8aa --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_chart.tsx @@ -0,0 +1,142 @@ +/* + * 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 } from '@kbn/lens-plugin/public'; +import type { DatatableColumn } from '@kbn/expressions-plugin/common'; +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; + +interface ComposeDiscoverChartProps { + query: string; + timeField: string; + timeRange: { from: string; to: string }; + columns: QueryColumn[]; +} + +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, +}) => { + const { lens, dataViews } = useRuleFormServices(); + 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..8cefb06e2d772 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_child.tsx @@ -0,0 +1,366 @@ +/* + * 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 { useFormContext } from 'react-hook-form'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiTitle, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiLoadingSpinner, + 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/code-editor'; +import type { FormValues } from '../../form/types'; +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'; +import { ComposeDiscoverChart } from './compose_discover_chart'; + +interface ComposeDiscoverChildProps { + state: ComposeDiscoverState; + dispatch: React.Dispatch; + 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'; + +export const ComposeDiscoverChild: React.FC = ({ + state, + dispatch, + 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 + // 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]); + + // 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({ + 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(); + + // 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]); + + // 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) { + // 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]); + + const { + columns, + rows, + totalRowCount, + isLoading, + isError, + error, + run, + hasRun, + lastExecutedQuery, + } = useQueryExecution({ + query: activeQuery, + timeField, + timeRange, + data: services.data, + }); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + run(); + } + }; + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [run]); + + const handleDone = useCallback(() => { + dispatch({ type: 'COMMIT_SANDBOX_QUERY', query: localQuery }); + onClose(); + }, [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 ? 'Edit alert query' : 'Define 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. Time field / date picker / Search row — one line ──────── */} +
+ + + setFormValue('timeField', e.target.value)} + compressed + prepend="Time field" + data-test-subj="composeDiscoverTimeField" + /> + + + { + dispatch({ type: 'SET_SANDBOX_DATE_RANGE', start, end }); + }} + showUpdateButton={false} + compressed + width="full" + /> + + + + + Search + + + + +
+ + {/* ── 2. Editor — bordered panel ──────────────────────────────── */} + + + + + {/* ── 3. Footer stats ─────────────────────────────────────────── */} + {hasRun && !isLoading && !isError && ( +
+ + {totalRowCount.toLocaleString()} {totalRowCount === 1 ? 'result' : 'results'} + +
+ )} + + {/* ── 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 && ( + <> + + + + + + + )} +
+
+ + + + + + 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..c8420403f53a6 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_flyout.tsx @@ -0,0 +1,252 @@ +/* + * 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 } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +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 } 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. +// For now they are passed as props to keep the package boundary clean. +export interface ComposeDiscoverFlyoutProps { + historyKey: symbol; + 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. */ + 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 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', + rule, + ruleId, + onClose, + services, + onCreateRule, + onUpdateRule, + isSaving = false, +}) => { + // ── UI state (step navigation, sandbox open/close, tab selection, etc.) ── + // 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); + + // ── 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 ?? [], + }; + } + 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(); + const isLastStep = uiState.step === stepTitles.length - 1; + + // 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.sandbox.query) { + methods.setValue('evaluation', { query: { base: uiState.sandbox.query } }); + } + }, [uiState.sandbox.query, uiState.queryCommitted, methods]); + + const handleSubmit = methods.handleSubmit((values) => { + if (isCreate) { + onCreateRule(mapFormValuesToCreateRequest(values)); + } else if (ruleId && onUpdateRule) { + onUpdateRule(ruleId, mapFormValuesToUpdateRequest(values)); + } + }); + + 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 ( + + + + + +

{title}

+
+ + {/* Step indicator coming in PR A — HorizontalMinimalStepper */} +
+ + + + + + + + + Cancel + + + + {uiState.step > 0 && ( + + dispatch({ type: 'GO_BACK' })} + data-test-subj="composeDiscoverBack" + > + Back + + + )} + + {isLastStep ? ( + + {isCreate ? 'Create rule' : 'Save rule'} + + ) : ( + + + Next + + + )} + + + + + + + {uiState.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..bbc59972838a5 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/compose_discover_form.tsx @@ -0,0 +1,259 @@ +/* + * 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 } 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, + EuiComboBox, + EuiFieldText, + EuiFormRow, + EuiHorizontalRule, + EuiPanel, + EuiSelect, + EuiSpacer, + EuiText, + 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'; +import { RuleDetailsFieldGroup } from '../../form'; +import { ScheduleField } from '../../form/fields/schedule_field'; +import { LookbackWindowField } from '../../form/fields/lookback_window_field'; + +interface ComposeDiscoverFormProps { + state: ComposeDiscoverState; + dispatch: React.Dispatch; + services: RuleFormServices; +} + +// ── Step content renderers ──────────────────────────────────────────────────── + +function AlertConditionStep({ + state, + dispatch, + services, +}: { + state: ComposeDiscoverState; + dispatch: React.Dispatch; + services: RuleFormServices; +}) { + const { setValue, watch } = useFormContext(); + const timeField = watch('timeField') ?? '@timestamp'; + const grouping = watch('grouping'); + const groupFields = grouping?.fields ?? []; + + 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: committedQuery, + 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]); + + // 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'); + interface 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 ( + <> + +

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 + + + ) : ( + <> + + + dispatch({ type: 'OPEN_CHILD_FOR_STEP', step: state.step })} + data-test-subj="composeDiscoverEditQuery" + > + Edit query + + + )} + + + + setValue('timeField', e.target.value)} + disabled={state.childOpen} + data-test-subj="composeDiscoverTimeField" + /> + + + + ({ label: name }))} + selectedOptions={groupFields.map((f) => ({ label: f }))} + onChange={(opts) => + setValue('grouping', opts.length ? { fields: opts.map((o) => o.label) } : undefined) + } + onCreateOption={(val) => setValue('grouping', { fields: [...groupFields, val] })} + placeholder="Add group fields" + data-test-subj="composeDiscoverGroupFields" + /> + + + + + + + + ); +} + +function DetailsAndArtifactsStep() { + return ( + <> + {/* Name, description, tags — connected to RHF via useFormContext() internally */} + + + + + +

Artifacts

+
+ + + {/* TODO (#268770): wire runbook URL and dashboard link to FormValues.artifacts */} + Optional}> + + + + + + 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() { + return ( + +

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

+
+ ); +} + +// ── Main form component ─────────────────────────────────────────────────────── + +export const ComposeDiscoverForm: React.FC = ({ + state, + dispatch, + services, +}) => { + // Route by step index — avoids silent breakage if step titles change + switch (state.step) { + case 0: + return ; + case 1: + return ; + case 2: + return ; + default: + return null; + } +}; 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..765df2f5ada9b --- /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/code-editor'; + +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..fa6f3b6997af5 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/types.ts @@ -0,0 +1,70 @@ +/* + * 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'; + +export type QueryTab = 'base' | 'alert' | 'recovery'; + +/** + * 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 interface SandboxTabConfig { + type: 'single'; +} + +/** + * Pending query values being edited in the Discover Sandbox. + * These are draft values — NOT yet committed to FormValues / the API. + * They become the source of truth only after the user clicks "Apply changes", + * which syncs them into RHF via the bridge in ComposeDiscoverFlyout. + * + * Tracking mode follow-up will extend this with: + * baseQuery: string; + * alertBlock: string; + * recoveryBlock: string; + */ +export interface SandboxDraft { + query: string; +} + +/** + * 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; + /** + * Pending (draft) query values being edited in the Sandbox. + * Not committed to FormValues until the user clicks "Apply changes". + */ + sandbox: SandboxDraft; + activeTab: QueryTab; + childOpen: boolean; + queryCommitted: boolean; + /** 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_SANDBOX_QUERY'; query: string } + | { type: 'SET_TAB'; tab: QueryTab } + | { type: 'SET_STEP'; step: number } + | { type: 'GO_NEXT' } + | { type: 'GO_BACK' } + | { type: 'SET_SANDBOX_DATE_RANGE'; start: string; end: string } + | { type: 'OPEN_CHILD' } + | { type: 'OPEN_CHILD_FOR_STEP'; step: number } + | { type: 'CLOSE_CHILD' } + | { type: 'COMMIT_SANDBOX_QUERY'; query: 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..db5e0b00c1e38 --- /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,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. + */ + +import { useReducer } from 'react'; +import type { + ComposeDiscoverState, + ComposeDiscoverAction, + ComposeDiscoverMode, + SandboxTabConfig, +} from './types'; + +const createInitialState = ( + mode: ComposeDiscoverMode, + initialSandboxQuery = '' +): ComposeDiscoverState => ({ + mode, + step: 0, + sandbox: { query: initialSandboxQuery }, + activeTab: 'alert', + childOpen: mode === 'create', + queryCommitted: mode === 'edit', + sandboxDateStart: 'now-15m', + sandboxDateEnd: 'now', +}); + +/** + * 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(): string[] { + return ['Alert Condition', 'Details & Artifacts', 'Notifications']; +} + +/** + * 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 { + return { type: 'single' }; +} + +function reducer(state: ComposeDiscoverState, action: ComposeDiscoverAction): ComposeDiscoverState { + switch (action.type) { + case 'SET_SANDBOX_QUERY': + return { ...state, sandbox: { ...state.sandbox, query: action.query } }; + case 'SET_TAB': + return { ...state, activeTab: action.tab }; + case 'SET_STEP': + return { ...state, step: action.step }; + case 'GO_NEXT': { + const steps = getStepTitles(); + 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 'SET_SANDBOX_DATE_RANGE': + return { ...state, sandboxDateStart: action.start, sandboxDateEnd: action.end }; + 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_SANDBOX_QUERY': + return { + ...state, + sandbox: { ...state.sandbox, query: action.query }, + childOpen: false, + queryCommitted: true, + }; + default: + return state; + } +} + +export const useComposeDiscoverState = ( + mode: ComposeDiscoverMode = 'create', + initialSandboxQuery = '' +) => { + return useReducer(reducer, undefined, () => createInitialState(mode, initialSandboxQuery)); +}; 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..f3d5f84f975e9 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_esql_providers.ts @@ -0,0 +1,73 @@ +/* + * 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, useRef } from 'react'; +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'; + +/** + * 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, + http: services.http, + 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(() => { + 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 () => { + disposables.forEach((d) => d.dispose()); + }; + // 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_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..77bb6cddeb326 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_heuristic_split.ts @@ -0,0 +1,91 @@ +/* + * 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 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..f047861bc0894 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/compose_discover/use_query_execution.ts @@ -0,0 +1,181 @@ +/* + * 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 { Parser } from '@elastic/esql'; +import type { EuiDataGridColumn } from '@elastic/eui'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; + +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; + + 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 = ({ + 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); + + 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; + + 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, 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..a2b3d4e97c29c --- /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,125 @@ +/* + * 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/code-editor'; +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) { + // 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); + baseQueryRef.current = baseQuery; + 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 (ctx) => + getEsqlColumns({ esqlQuery: ctx?.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; + + 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. + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + return { + suggestions: rawSuggestions.map((s) => ({ + label: s.label, + insertText: s.text, + insertTextRules: s.asSnippet + ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + : undefined, + kind: Object.prototype.hasOwnProperty.call(monaco.languages.CompletionItemKind, s.kind) + ? monaco.languages.CompletionItemKind[ + s.kind as keyof typeof monaco.languages.CompletionItemKind + ] + : monaco.languages.CompletionItemKind.Field, + detail: s.detail, + sortText: s.sortText, + filterText: s.filterText, + range, + })), + }; + }, + }); + }, []); // stable — reads baseQuery and search via refs + + // Clean up the provider when the component using this hook unmounts. + useEffect(() => { + return () => { + disposableRef.current?.dispose(); + disposableRef.current = null; + }; + }, []); + + return { onEditorMount }; +} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/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']) => ({ 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, 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..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 @@ -41,6 +41,8 @@ dependsOn: - '@kbn/lens-plugin' - '@kbn/visualization-utils' - '@kbn/expressions-plugin' + - '@kbn/code-editor' + - '@kbn/esql-language' tags: - shared-browser - package 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..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 @@ -38,7 +38,9 @@ "@kbn/field-types", "@kbn/lens-plugin", "@kbn/visualization-utils", - "@kbn/expressions-plugin" + "@kbn/expressions-plugin", + "@kbn/code-editor", + "@kbn/esql-language" ] } 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', + }) + ); + }, + }); +}; 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 () => { 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..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,26 +7,35 @@ 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'; +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 { 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'; 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'; -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 +57,26 @@ 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 [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(); + const updateRuleMutation = useUpdateRule(); + 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 +101,12 @@ export const RulesListPage = () => { setPage(1); }, [debouncedSearch, filter]); - const { data, isLoading, isError, error } = useFetchRules({ + const { + data: rulesData, + isLoading, + isError, + error, + } = useFetchRules({ page, perPage, filter, @@ -120,17 +150,65 @@ export const RulesListPage = () => { /> } rightSideItems={[ - - + application.navigateToUrl( + http.basePath.prepend('/app/management/alertingV2/rules/create') + ) + } + data-test-subj="createRuleButton" + > + + + { + closeCreateMenu(); + setFlyoutOpen(true); + }, + 'data-test-subj': 'createRuleFlyoutButton', + }, + ], + }, + ]} + /> + ), + }} /> - , + , ]} /> @@ -181,8 +259,8 @@ export const RulesListPage = () => { { sortDirection={sortDirection} isLoading={isLoading} onTableChange={onTableChange} + onEditInFlyout={(rule) => { + setEditRule(rule); + setFlyoutOpen(true); + }} /> ) : null} + {flyoutOpen && ( + { + 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} + /> + )} ); }; 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) => 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(); - }); - }); -});