From 1ae1b8cb766c92739ab5b0d7eea0c8b9d7e36224 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Thu, 22 Jan 2026 10:44:22 +0000 Subject: [PATCH 1/8] wip rules v2 list and editor --- .../common/schemas/create_rule_data_schema.ts | 57 + .../shared/alerting_v2/common/types.ts | 11 + .../shared/alerting_v2/common/validation.ts | 23 + .../plugins/shared/alerting_v2/kibana.jsonc | 3 +- .../alerting_v2/public/components/app.tsx | 27 + .../public/components/create_rule_page.tsx | 1130 +++++++++++++++++ .../public/components/rules_list_page.tsx | 222 ++++ .../shared/alerting_v2/public/constants.ts | 10 + .../shared/alerting_v2/public/index.ts | 36 + .../shared/alerting_v2/public/main.tsx | 66 + .../alerting_v2/public/services/rules_api.ts | 61 + .../shared/alerting_v2/server/lib/duration.ts | 8 +- .../server/lib/rules_client/rules_client.ts | 35 +- .../schemas/create_rule_data_schema.ts | 20 +- .../schemas/update_rule_data_schema.ts | 22 +- .../server/lib/rules_client/types.ts | 9 +- .../server/lib/rules_client/validators.ts | 9 +- .../server/routes/create_rule_route.ts | 3 +- .../server/routes/route_validation.ts | 20 + .../server/routes/update_rule_route.ts | 3 +- .../plugins/shared/alerting_v2/tsconfig.json | 8 + 21 files changed, 1705 insertions(+), 78 deletions(-) create mode 100644 x-pack/platform/plugins/shared/alerting_v2/common/schemas/create_rule_data_schema.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/common/types.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/common/validation.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/components/app.tsx create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/components/rules_list_page.tsx create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/constants.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/index.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/main.tsx create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/services/rules_api.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/route_validation.ts diff --git a/x-pack/platform/plugins/shared/alerting_v2/common/schemas/create_rule_data_schema.ts b/x-pack/platform/plugins/shared/alerting_v2/common/schemas/create_rule_data_schema.ts new file mode 100644 index 0000000000000..a119ccfdc1b0c --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/common/schemas/create_rule_data_schema.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod'; +import { validateDuration, validateEsqlQuery } from '../validation'; + +const durationSchema = z.string().superRefine((value, ctx) => { + const error = validateDuration(value); + if (error) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: error }); + } +}); + +const esqlQuerySchema = z + .string() + .min(1) + .max(10000) + .superRefine((value, ctx) => { + const error = validateEsqlQuery(value); + if (error) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: error }); + } + }); + +export const createRuleDataSchema = z + .object({ + name: z.string().min(1).max(64).describe('Human-readable rule name.'), + tags: z + .array(z.string().max(64).describe('Rule tag.')) + .max(100) + .default([]) + .describe('Tags attached to the rule.'), + schedule: z + .object({ + custom: durationSchema.describe('Rule execution interval (e.g. 1m, 5m).'), + }) + .describe('Schedule configuration for the rule.'), + enabled: z.boolean().default(true).describe('Whether the rule is enabled.'), + query: esqlQuerySchema.describe('ES|QL query text to execute.'), + timeField: z + .string() + .min(1) + .max(128) + .default('@timestamp') + .describe('Time field to apply the lookback window to.'), + lookbackWindow: durationSchema.describe('Lookback window for the query (e.g. 5m, 1h).'), + groupingKey: z + .array(z.string()) + .max(16) + .default([]) + .describe('Fields to group alert events by.'), + }) + .strip(); diff --git a/x-pack/platform/plugins/shared/alerting_v2/common/types.ts b/x-pack/platform/plugins/shared/alerting_v2/common/types.ts new file mode 100644 index 0000000000000..67347d639f43b --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/common/types.ts @@ -0,0 +1,11 @@ +/* + * 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 type { TypeOf as ZodTypeOf } from '@kbn/zod'; +import type { createRuleDataSchema } from './schemas/create_rule_data_schema'; + +export type CreateRuleData = ZodTypeOf; diff --git a/x-pack/platform/plugins/shared/alerting_v2/common/validation.ts b/x-pack/platform/plugins/shared/alerting_v2/common/validation.ts new file mode 100644 index 0000000000000..5bc4be445de12 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/common/validation.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Parser } from '@kbn/esql-language'; + +const DURATION_RE = /^(\d+)(ms|s|m|h|d|w)$/; + +export function validateDuration(value: string): string | void { + if (!DURATION_RE.test(value)) { + return `Invalid duration "${value}". Expected format like "5m", "1h", "30s", "250ms"`; + } +} + +export const validateEsqlQuery = (query: string): string | void => { + const errors = Parser.parseErrors(query); + if (errors.length > 0) { + return `Invalid ES|QL query: ${errors[0].message}`; + } +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc b/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc index 3dfe8e311732f..67b7e82e84c56 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc +++ b/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc @@ -6,7 +6,7 @@ "visibility": "shared", "plugin": { "id": "alertingVTwo", - "browser": false, + "browser": true, "server": true, "configPath": ["xpack", "alerting_v2"], "requiredPlugins": [ @@ -16,6 +16,7 @@ "data", "security" ], + "optionalPlugins": ["management"], "extraPublicDirs": [] } } diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/app.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/app.tsx new file mode 100644 index 0000000000000..7d68e7a1a40a7 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/app.tsx @@ -0,0 +1,27 @@ +/* + * 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 { Route, Routes } from '@kbn/shared-ux-router'; +import { CreateRulePage } from './create_rule_page'; +import { RulesListPage } from './rules_list_page'; + +export const App = () => { + return ( + + + + + + + + + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx new file mode 100644 index 0000000000000..6452914a27726 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx @@ -0,0 +1,1130 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiPageHeader, + EuiSpacer, +} from '@elastic/eui'; +import { CodeEditorField } from '@kbn/code-editor'; +import { + ESQL_AUTOCOMPLETE_TRIGGER_CHARS, + ESQL_LANG_ID, + ESQLLang, + monaco, + YamlLang, + YAML_LANG_ID, +} from '@kbn/monaco'; +import { useService, CoreStart } from '@kbn/core-di-browser'; +import { PluginStart } from '@kbn/core-di'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { ESQLCallbacks } from '@kbn/esql-types'; +import { getESQLSources, getEsqlColumns } from '@kbn/esql-utils'; +import { suggest } from '@kbn/esql-language'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { dump, load } from 'js-yaml'; +import { useHistory, useParams } from 'react-router-dom'; +import YAML, { LineCounter } from 'yaml'; +import { createRuleDataSchema } from '../../common/schemas/create_rule_data_schema'; +import type { CreateRuleData } from '../../common/types'; +import { RulesApi } from '../services/rules_api'; + +const DEFAULT_RULE_YAML = `name: Example rule +tags: [] +schedule: + custom: 1m +enabled: true +query: FROM logs-* | LIMIT 1 +timeField: "@timestamp" +lookbackWindow: 5m +groupingKey: []`; + +const DEFAULT_RULE_VALUES: CreateRuleData = { + name: 'Example rule', + tags: [], + schedule: { custom: '1m' }, + enabled: true, + query: 'FROM logs-* | LIMIT 1', + timeField: '@timestamp', + lookbackWindow: '5m', + groupingKey: [], +}; + +const getErrorMessage = (error: unknown) => { + if (error instanceof Error) { + return error.message; + } + return String(error); +}; + +const parseYaml = (value: string): Record | null => { + try { + const result = load(value); + if (!result || typeof result !== 'object' || Array.isArray(result)) { + return null; + } + return result as Record; + } catch { + return null; + } +}; + +interface QueryContext { + queryText: string; + queryOffset: number; +} + +const createRuleJsonSchema = zodToJsonSchema(createRuleDataSchema, { + name: 'CreateRuleData', + $refStrategy: 'none', +}); + +const resolveSchemaRef = (schema: any, ref?: string) => { + if (!ref || !schema || typeof schema !== 'object') { + return schema; + } + if (!ref.startsWith('#/')) { + return schema; + } + const path = ref + .slice(2) + .split('/') + .map((segment) => decodeURIComponent(segment)); + let current = schema; + for (const segment of path) { + if (!current || typeof current !== 'object') { + return schema; + } + current = current[segment]; + } + return current ?? schema; +}; + +const getRootSchema = () => { + const definitionsRoot = createRuleJsonSchema.definitions?.CreateRuleData; + if (definitionsRoot) { + return definitionsRoot; + } + const schemaWithRef = createRuleJsonSchema as { $ref?: string }; + if (schemaWithRef.$ref) { + return resolveSchemaRef(createRuleJsonSchema, schemaWithRef.$ref); + } + return createRuleJsonSchema; +}; + +const getSchemaNode = (path: Array) => { + let current: any = getRootSchema(); + for (const segment of path) { + if (!current || typeof current !== 'object') { + return undefined; + } + if (current.$ref) { + current = resolveSchemaRef(createRuleJsonSchema, current.$ref); + } + if (typeof segment === 'number') { + current = current.items; + continue; + } + current = current.properties?.[segment]; + } + if (current?.$ref) { + return resolveSchemaRef(createRuleJsonSchema, current.$ref); + } + return current; +}; + +const getSchemaDescription = (path: string[]) => { + return getSchemaNode(path)?.description as string | undefined; +}; + +const getSchemaProperties = (path: string[]) => { + const node = getSchemaNode(path); + if (!node || typeof node !== 'object') { + return []; + } + return Object.entries(node.properties ?? {}).map(([key, value]) => ({ + key, + schema: value as { description?: string; type?: string }, + })); +}; + +const getCompletionContext = (text: string, position: monaco.Position) => { + const lines = text.split('\n'); + const lineIndex = Math.max(0, position.lineNumber - 1); + const line = lines[lineIndex] ?? ''; + const indent = line.match(/^\s*/)?.[0].length ?? 0; + const keyMatch = /^(\s*)([A-Za-z0-9_]+)\s*:(.*)$/.exec(line); + + if (keyMatch) { + const keyIndent = keyMatch[1].length; + const key = keyMatch[2]; + const hasValue = keyMatch[3].trim().length > 0; + const isValuePosition = hasValue || position.column > keyMatch[0].indexOf(':') + 1; + const parentPath = keyIndent === 0 ? [] : getYamlPathAtPosition(text, position)?.slice(0, -1); + return { + parentPath: parentPath ?? [], + currentKey: key, + isValuePosition, + }; + } + + if (indent === 0) { + return { parentPath: [], currentKey: null, isValuePosition: false }; + } + + for (let i = lineIndex - 1; i >= 0; i--) { + const match = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(lines[i]); + if (!match) { + continue; + } + const parentIndent = match[1].length; + if (parentIndent < indent) { + return { parentPath: [match[2]], currentKey: null, isValuePosition: false }; + } + } + + return { parentPath: [], currentKey: null, isValuePosition: false }; +}; + +const findYamlNodeForPath = (doc: YAML.Document, path: Array) => { + let node: YAML.Node | null | undefined = doc.contents; + let lastPair: YAML.Pair | null = null; + + for (const segment of path) { + if (YAML.isMap(node)) { + const pair = node.items.find( + (item) => YAML.isScalar(item.key) && item.key.value === segment + ) as YAML.Pair | undefined; + if (!pair) { + return { node: null, pair: null }; + } + lastPair = pair; + node = pair.value; + continue; + } + + if (YAML.isSeq(node) && typeof segment === 'number') { + const nextNode = node.items[segment] as YAML.Node | undefined; + if (!nextNode) { + return { node: null, pair: null }; + } + node = nextNode; + continue; + } + + return { node: null, pair: null }; + } + + return { node, pair: lastPair }; +}; + +const toMonacoPosition = (linePos: { line: number; col: number }) => { + return { + lineNumber: linePos.line > 0 ? linePos.line : 1, + column: linePos.col > 0 ? linePos.col : 1, + }; +}; + +const getRangeFromOffsets = (lineCounter: LineCounter, start: number, end: number) => { + const startPos = toMonacoPosition(lineCounter.linePos(start)); + const endPos = toMonacoPosition(lineCounter.linePos(end)); + return new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column); +}; + +const buildYamlValidationMarkers = (model: monaco.editor.ITextModel) => { + const text = model.getValue(); + const lineCounter = new LineCounter(); + const doc = YAML.parseDocument(text, { lineCounter }); + const markers: monaco.editor.IMarkerData[] = []; + + for (const error of doc.errors) { + const [start, end] = error.pos ?? [0, 0]; + const range = getRangeFromOffsets(lineCounter, start, Math.max(end, start + 1)); + markers.push({ + message: error.message, + severity: monaco.MarkerSeverity.Error, + startLineNumber: range.startLineNumber, + startColumn: range.startColumn, + endLineNumber: range.endLineNumber, + endColumn: range.endColumn, + }); + } + + if (doc.errors.length === 0) { + const parsed = createRuleDataSchema.safeParse(doc.toJS()); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + const path = issue.path as Array; + const { node, pair } = findYamlNodeForPath(doc, path); + const rangeSource = pair?.key ?? node; + const range = rangeSource?.range + ? getRangeFromOffsets(lineCounter, rangeSource.range[0], rangeSource.range[1]) + : getRangeFromOffsets(lineCounter, 0, 1); + markers.push({ + message: issue.message, + severity: monaco.MarkerSeverity.Error, + startLineNumber: range.startLineNumber, + startColumn: range.startColumn, + endLineNumber: range.endLineNumber, + endColumn: range.endColumn, + }); + } + } + } + + monaco.editor.setModelMarkers(model, 'alertingV2YamlSchema', markers); +}; + +const getYamlPathAtPosition = (text: string, position: monaco.Position): string[] | null => { + const lines = text.split('\n'); + const lineIndex = Math.max(0, position.lineNumber - 1); + const line = lines[lineIndex] ?? ''; + const indent = line.match(/^\s*/)?.[0].length ?? 0; + const keyMatch = /^\s*([A-Za-z0-9_]+)\s*:/.exec(line); + + if (keyMatch) { + const key = keyMatch[1]; + if (indent === 0) { + return [key]; + } + for (let i = lineIndex - 1; i >= 0; i--) { + const parentLine = lines[i]; + const parentMatch = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(parentLine); + if (!parentMatch) { + continue; + } + const parentIndent = parentMatch[1].length; + if (parentIndent < indent) { + return [parentMatch[2], key]; + } + } + return [key]; + } + + const cursorOffset = lines.slice(0, lineIndex).reduce((acc, curr) => acc + curr.length + 1, 0); + const queryContext = findYamlQueryContext(text, cursorOffset + position.column - 1); + if (queryContext) { + return ['query']; + } + + for (let i = lineIndex - 1; i >= 0; i--) { + const parentLine = lines[i]; + const parentMatch = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(parentLine); + if (!parentMatch) { + continue; + } + const parentIndent = parentMatch[1].length; + if (parentIndent < indent) { + return [parentMatch[2]]; + } + } + + return null; +}; + +const ALERTING_V2_YAML_ESQL_LANG_ID = 'alertingV2YamlEsql'; + +class AlertingYamlState implements monaco.languages.IState { + constructor( + public readonly kind: 'none' | 'pending' | 'block' | 'inline', + public readonly baseIndent: number, + public readonly blockIndent: number + ) {} + + clone() { + return new AlertingYamlState(this.kind, this.baseIndent, this.blockIndent); + } + + equals(other: monaco.languages.IState) { + if (!(other instanceof AlertingYamlState)) { + return false; + } + return ( + other.kind === this.kind && + other.baseIndent === this.baseIndent && + other.blockIndent === this.blockIndent + ); + } +} + +const ensureAlertingYamlLanguage = () => { + const languages = monaco.languages.getLanguages(); + if (languages.some(({ id }) => id === ALERTING_V2_YAML_ESQL_LANG_ID)) { + return; + } + + void ESQLLang.onLanguage?.(); + + monaco.languages.register({ id: ALERTING_V2_YAML_ESQL_LANG_ID }); + if (YamlLang.languageConfiguration) { + monaco.languages.setLanguageConfiguration( + ALERTING_V2_YAML_ESQL_LANG_ID, + YamlLang.languageConfiguration + ); + } + + const normalizeEsqlTokenType = (tokenType: string) => { + const [base] = tokenType.split('.'); + return base || tokenType; + }; + + const toMonacoTokens = ( + tokens: monaco.Token[] | undefined, + transform?: (tokenType: string) => string + ): monaco.languages.IToken[] => { + if (!tokens) { + return []; + } + return tokens.map((token) => ({ + startIndex: token.offset, + scopes: transform ? transform(token.type) : token.type, + })); + }; + + const tokenizeYamlLine = (line: string) => { + const yamlTokens = monaco.editor.tokenize(line, YAML_LANG_ID)[0]; + return toMonacoTokens(yamlTokens); + }; + + const tokenizeEsqlLine = (line: string, offset: number) => { + const esqlTokens = monaco.editor.tokenize(line, ESQL_LANG_ID)[0]; + return toMonacoTokens(esqlTokens, normalizeEsqlTokenType).map((token) => ({ + startIndex: token.startIndex + offset, + scopes: token.scopes, + })); + }; + + monaco.languages.setTokensProvider(ALERTING_V2_YAML_ESQL_LANG_ID, { + getInitialState: () => new AlertingYamlState('none', 0, 0), + tokenize: (line, state) => { + if (!(state instanceof AlertingYamlState)) { + return { tokens: tokenizeYamlLine(line), endState: new AlertingYamlState('none', 0, 0) }; + } + + const indent = line.match(/^\s*/)?.[0].length ?? 0; + const trimmed = line.trim(); + + if (state.kind === 'pending') { + if (trimmed === '') { + return { tokens: tokenizeYamlLine(line), endState: state }; + } + + if (indent > state.baseIndent) { + const esqlText = line.slice(indent); + const tokens = [ + { startIndex: 0, scopes: 'source.yaml' }, + ...tokenizeEsqlLine(esqlText, indent), + ]; + return { + tokens, + endState: new AlertingYamlState('block', state.baseIndent, indent), + }; + } + + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('none', 0, 0), + }; + } + + if (state.kind === 'block') { + if (trimmed !== '' && indent < state.blockIndent) { + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('none', 0, 0), + }; + } + + const esqlText = indent >= state.blockIndent ? line.slice(state.blockIndent) : ''; + const tokens = [ + { startIndex: 0, scopes: 'source.yaml' }, + ...tokenizeEsqlLine(esqlText, state.blockIndent), + ]; + return { + tokens, + endState: state, + }; + } + + if (state.kind === 'inline') { + if (trimmed === '' && indent <= state.baseIndent) { + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('none', 0, 0), + }; + } + + if (trimmed !== '' && indent <= state.baseIndent) { + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('none', 0, 0), + }; + } + + const continuationIndent = + state.blockIndent > 0 ? state.blockIndent : indent > state.baseIndent ? indent : 0; + + if (continuationIndent === 0) { + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('none', 0, 0), + }; + } + + const esqlText = indent >= continuationIndent ? line.slice(continuationIndent) : ''; + const tokens = [ + { startIndex: 0, scopes: 'source.yaml' }, + ...tokenizeEsqlLine(esqlText, continuationIndent), + ]; + return { + tokens, + endState: new AlertingYamlState('inline', state.baseIndent, continuationIndent), + }; + } + + const queryMatch = /^(\s*)query:\s*(.*)$/.exec(line); + if (!queryMatch) { + return { tokens: tokenizeYamlLine(line), endState: state }; + } + + const baseIndent = queryMatch[1].length; + const value = queryMatch[2] ?? ''; + const valueStartIndex = line.indexOf(value); + const valueStartOffset = Math.max(valueStartIndex, 0); + const trimmedValue = value.trim(); + + if (trimmedValue.startsWith('>') || trimmedValue.startsWith('|')) { + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('pending', baseIndent, 0), + }; + } + + if (trimmedValue.length > 0) { + const rawValue = value.trimStart(); + const rawValueOffset = valueStartOffset + (value.length - rawValue.length); + const quote = rawValue.startsWith('"') || rawValue.startsWith("'") ? rawValue[0] : null; + const closingIndex = quote ? rawValue.lastIndexOf(quote) : rawValue.length; + const queryText = rawValue.slice( + quote ? 1 : 0, + closingIndex > 0 ? closingIndex : undefined + ); + const queryOffset = rawValueOffset + (quote ? 1 : 0); + const tokens = [ + { startIndex: 0, scopes: 'source.yaml' }, + ...tokenizeEsqlLine(queryText, queryOffset), + ]; + return { tokens, endState: new AlertingYamlState('inline', baseIndent, 0) }; + } + + return { tokens: tokenizeYamlLine(line), endState: state }; + }, + }); +}; + +ensureAlertingYamlLanguage(); + +const findYamlQueryContext = (text: string, cursorOffset: number): QueryContext | null => { + const lines = text.split('\n'); + const lineStartOffsets: number[] = []; + let runningOffset = 0; + let cursorLine = 0; + + for (let i = 0; i < lines.length; i++) { + lineStartOffsets.push(runningOffset); + const nextOffset = runningOffset + lines[i].length + 1; + if (cursorOffset < nextOffset) { + cursorLine = i; + } + runningOffset = nextOffset; + } + + for (let i = cursorLine; i >= 0; i--) { + const line = lines[i]; + const match = /^(\s*)query:\s*(.*)$/.exec(line); + if (!match) { + continue; + } + + const baseIndent = match[1].length; + const value = match[2] ?? ''; + const valueStartIndex = line.indexOf(value); + const valueStartOffset = lineStartOffsets[i] + Math.max(valueStartIndex, 0); + + if (value.trim().length > 0 && !/^[>|]/.test(value.trim())) { + const rawValue = value.trimStart(); + const rawValueOffset = valueStartOffset + (value.length - rawValue.length); + const quote = rawValue.startsWith('"') || rawValue.startsWith("'") ? rawValue[0] : null; + const closingIndex = quote ? rawValue.lastIndexOf(quote) : rawValue.length; + const queryStartOffset = rawValueOffset + (quote ? 1 : 0); + const queryEndOffset = + closingIndex > 0 ? rawValueOffset + closingIndex : rawValueOffset + rawValue.length; + + let continuationIndent = 0; + for (let j = i + 1; j < lines.length; j++) { + const trimmedLine = lines[j].trim(); + if (trimmedLine === '') { + continue; + } + const indent = lines[j].match(/^\s*/)?.[0].length ?? 0; + if (indent > baseIndent) { + continuationIndent = indent; + } + break; + } + + if (continuationIndent === 0) { + if (cursorOffset < queryStartOffset || cursorOffset > queryEndOffset) { + return null; + } + const queryText = rawValue.slice( + quote ? 1 : 0, + closingIndex > 0 ? closingIndex : undefined + ); + const queryOffset = Math.max( + 0, + Math.min(queryText.length, cursorOffset - queryStartOffset) + ); + return { queryText, queryOffset }; + } + + let endLine = i + 1; + while (endLine < lines.length) { + const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; + if (lines[endLine].trim() !== '' && lineIndent <= baseIndent) { + break; + } + endLine += 1; + } + + const firstLineQueryText = rawValue.slice( + quote ? 1 : 0, + closingIndex > 0 ? closingIndex : undefined + ); + const continuationLines = lines.slice(i + 1, endLine).map((lineText) => { + const lineIndent = lineText.match(/^\s*/)?.[0].length ?? 0; + if (lineIndent > baseIndent) { + const sliceIndent = Math.min(continuationIndent, lineIndent); + return lineText.slice(sliceIndent); + } + return ''; + }); + const queryText = [firstLineQueryText, ...continuationLines].join('\n'); + + if (cursorLine < i || cursorLine >= endLine) { + return null; + } + + if (cursorLine === i) { + if (cursorOffset < queryStartOffset) { + return null; + } + const queryOffset = Math.max( + 0, + Math.min(firstLineQueryText.length, cursorOffset - queryStartOffset) + ); + return { queryText, queryOffset }; + } + + const cursorLineOffset = cursorOffset - lineStartOffsets[cursorLine]; + const cursorOffsetInQueryLine = Math.max(0, cursorLineOffset - continuationIndent); + const lineOffsetInQuery = + firstLineQueryText.length + + 1 + + continuationLines + .slice(0, cursorLine - (i + 1)) + .reduce((acc, curr) => acc + curr.length + 1, 0) + + cursorOffsetInQueryLine; + + return { + queryText, + queryOffset: Math.max(0, Math.min(queryText.length, lineOffsetInQuery)), + }; + } + + if (i + 1 >= lines.length) { + return null; + } + + let blockIndent = 0; + let firstContentLine = -1; + for (let j = i + 1; j < lines.length; j++) { + const trimmedLine = lines[j].trim(); + if (trimmedLine === '') { + continue; + } + const indent = lines[j].match(/^\s*/)?.[0].length ?? 0; + if (indent > baseIndent) { + blockIndent = indent; + firstContentLine = j; + } + break; + } + + if (blockIndent === 0) { + if (cursorLine <= i) { + return null; + } + const cursorIndent = lines[cursorLine].match(/^\s*/)?.[0].length ?? 0; + blockIndent = Math.max(baseIndent + 1, cursorIndent); + firstContentLine = i + 1; + } + + let endLine = firstContentLine; + while (endLine < lines.length) { + const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; + if (lines[endLine].trim() !== '' && lineIndent < blockIndent) { + break; + } + endLine += 1; + } + + const queryLines = lines.slice(i + 1, endLine).map((lineText) => { + if (lineText.length >= blockIndent) { + return lineText.slice(blockIndent); + } + return ''; + }); + + const queryText = queryLines.join('\n'); + if (cursorLine < i + 1 || cursorLine >= endLine) { + return null; + } + + const cursorLineOffset = cursorOffset - lineStartOffsets[cursorLine]; + const cursorOffsetInQueryLine = Math.max(0, cursorLineOffset - blockIndent); + + const lineOffsetInQuery = + queryLines.slice(0, cursorLine - (i + 1)).reduce((acc, curr) => acc + curr.length + 1, 0) + + cursorOffsetInQueryLine; + + return { + queryText, + queryOffset: Math.max(0, Math.min(queryText.length, lineOffsetInQuery)), + }; + } + + return null; +}; + +const toCompletionItems = ( + suggestions: Array<{ + label: string; + text: string; + asSnippet?: boolean; + kind?: string; + detail?: string; + documentation?: string | { value: string }; + sortText?: string; + filterText?: string; + }>, + range: monaco.Range +): monaco.languages.CompletionItem[] => { + return suggestions.map((item) => { + const kind = + item.kind && item.kind in monaco.languages.CompletionItemKind + ? monaco.languages.CompletionItemKind[ + item.kind as keyof typeof monaco.languages.CompletionItemKind + ] + : monaco.languages.CompletionItemKind.Method; + return { + label: item.label, + insertText: item.text, + filterText: item.filterText, + kind, + detail: item.detail, + documentation: item.documentation, + sortText: item.sortText, + insertTextRules: item.asSnippet + ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + : undefined, + range, + }; + }); +}; + +export const CreateRulePage = () => { + const { id: ruleId } = useParams<{ id?: string }>(); + const isEditing = Boolean(ruleId); + const history = useHistory(); + const rulesApi = useService(RulesApi); + const http = useService(CoreStart('http')); + const application = useService(CoreStart('application')); + const data = useService(PluginStart('data')) as DataPublicPluginStart; + const editorSuggestDisposable = useRef(null); + const editorValidationDisposable = useRef(null); + const [yaml, setYaml] = useState(DEFAULT_RULE_YAML); + const [error, setError] = useState(null); + const [errorTitle, setErrorTitle] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isLoadingRule, setIsLoadingRule] = useState(false); + + const parsedDoc = useMemo(() => parseYaml(yaml), [yaml]); + + const esqlCallbacks = useMemo( + () => ({ + getSources: async () => getESQLSources({ application, http }, undefined), + getColumnsFor: async ({ query }: { query?: string } | undefined = {}) => + getEsqlColumns({ esqlQuery: query, search: data.search.search }), + }), + [application, http, data.search.search] + ); + + const suggestionProvider = useMemo( + () => ({ + triggerCharacters: [...ESQL_AUTOCOMPLETE_TRIGGER_CHARS, ':', ' '], + provideCompletionItems: async (model, position) => { + const fullText = model.getValue(); + const cursorOffset = model.getOffsetAt(position); + const queryContext = findYamlQueryContext(fullText, cursorOffset); + + const word = model.getWordUntilPosition(position); + const range = new monaco.Range( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn + ); + + if (queryContext) { + const suggestions = await suggest( + queryContext.queryText, + queryContext.queryOffset, + esqlCallbacks + ); + return { + suggestions: toCompletionItems(suggestions, range), + }; + } + + const completionContext = getCompletionContext(fullText, position); + if (!completionContext) { + return { suggestions: [] }; + } + + if (completionContext.isValuePosition && completionContext.currentKey) { + const schemaNode = getSchemaNode([ + ...completionContext.parentPath, + completionContext.currentKey, + ]) as { type?: string; enum?: string[] } | undefined; + if (schemaNode?.type === 'boolean') { + return { + suggestions: ['true', 'false'].map((value) => ({ + label: value, + insertText: value, + kind: monaco.languages.CompletionItemKind.Value, + range, + })), + }; + } + if (schemaNode?.enum) { + return { + suggestions: schemaNode.enum.map((value) => ({ + label: value, + insertText: value, + kind: monaco.languages.CompletionItemKind.Value, + range, + })), + }; + } + return { suggestions: [] }; + } + + const properties = getSchemaProperties(completionContext.parentPath); + return { + suggestions: properties.map(({ key, schema }) => ({ + label: key, + insertText: `${key}: `, + kind: monaco.languages.CompletionItemKind.Property, + documentation: schema.description ? { value: schema.description } : undefined, + range, + })), + }; + }, + }), + [esqlCallbacks] + ); + + const hoverProvider = useMemo( + () => ({ + provideHover: (model, position) => { + const path = getYamlPathAtPosition(model.getValue(), position); + if (!path) { + return null; + } + const description = getSchemaDescription(path); + if (!description) { + return null; + } + return { + contents: [{ value: description }], + }; + }, + }), + [] + ); + + useEffect(() => { + if (!ruleId) { + return; + } + + let cancelled = false; + const loadRule = async () => { + setIsLoadingRule(true); + setError(null); + setErrorTitle(null); + + try { + const rule = await rulesApi.getRule(ruleId); + if (cancelled) { + return; + } + + const nextPayload: CreateRuleData = { + ...DEFAULT_RULE_VALUES, + name: rule.name, + tags: rule.tags ?? DEFAULT_RULE_VALUES.tags, + schedule: rule.schedule?.custom + ? { custom: rule.schedule.custom } + : DEFAULT_RULE_VALUES.schedule, + enabled: rule.enabled ?? DEFAULT_RULE_VALUES.enabled, + query: rule.query ?? DEFAULT_RULE_VALUES.query, + timeField: rule.timeField ?? DEFAULT_RULE_VALUES.timeField, + lookbackWindow: rule.lookbackWindow ?? DEFAULT_RULE_VALUES.lookbackWindow, + groupingKey: rule.groupingKey ?? DEFAULT_RULE_VALUES.groupingKey, + }; + + setYaml(dump(nextPayload, { lineWidth: 120, noRefs: true })); + } catch (err) { + if (!cancelled) { + setErrorTitle( + + ); + setError(getErrorMessage(err)); + } + } finally { + if (!cancelled) { + setIsLoadingRule(false); + } + } + }; + + loadRule(); + + return () => { + cancelled = true; + }; + }, [ruleId, rulesApi]); + + const onSave = async () => { + setIsSubmitting(true); + setError(null); + setErrorTitle(null); + + try { + if (!parsedDoc) { + setErrorTitle( + + ); + setError( + + ); + setIsSubmitting(false); + return; + } + + const validated = createRuleDataSchema.safeParse(parsedDoc); + if (!validated.success) { + setErrorTitle( + + ); + setError(validated.error.message); + setIsSubmitting(false); + return; + } + + if (isEditing && ruleId) { + await rulesApi.updateRule(ruleId, validated.data); + } else { + await rulesApi.createRule(validated.data); + } + + history.push('/'); + } catch (err) { + setErrorTitle( + + ); + setError(getErrorMessage(err)); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <> + + ) : ( + + ) + } + /> + + + {error ? ( + <> + + ) + } + color="danger" + iconType="error" + announceOnMount + > + {error} + + + + ) : null} + + } + fullWidth + helpText={ + + } + > + setYaml(value)} + languageId={ALERTING_V2_YAML_ESQL_LANG_ID} + editorDidMount={(editor) => { + const model = editor.getModel(); + if (!model) { + return; + } + // Force tokenization on initial render for the custom YAML+ESQL language. + monaco.editor.setModelLanguage(model, YAML_LANG_ID); + monaco.editor.setModelLanguage(model, ALERTING_V2_YAML_ESQL_LANG_ID); + + editorSuggestDisposable.current?.dispose(); + editorSuggestDisposable.current = editor.onDidChangeModelContent(() => { + const position = editor.getPosition(); + const currentModel = editor.getModel(); + if (!position || !currentModel) { + return; + } + const offset = currentModel.getOffsetAt(position); + if (findYamlQueryContext(currentModel.getValue(), offset)) { + editor.trigger('alerting_v2', 'editor.action.triggerSuggest', null); + } + }); + + buildYamlValidationMarkers(model); + editorValidationDisposable.current?.dispose(); + editorValidationDisposable.current = editor.onDidChangeModelContent(() => { + buildYamlValidationMarkers(model); + }); + }} + editorWillUnmount={() => { + editorSuggestDisposable.current?.dispose(); + editorSuggestDisposable.current = null; + editorValidationDisposable.current?.dispose(); + editorValidationDisposable.current = null; + }} + height={320} + fullWidth + dataTestSubj="alertingV2CreateRuleYaml" + suggestionProvider={suggestionProvider} + hoverProvider={hoverProvider} + options={{ + minimap: { enabled: false }, + wordWrap: 'on', + lineNumbers: 'on', + readOnly: isLoadingRule || isSubmitting, + }} + /> + + + + + + {isEditing ? ( + + ) : ( + + )} + + + + history.push('/')} data-test-subj="cancelCreateRule"> + + + + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/rules_list_page.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/rules_list_page.tsx new file mode 100644 index 0000000000000..42e8959a7bc70 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/rules_list_page.tsx @@ -0,0 +1,222 @@ +/* + * 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, useState } from 'react'; +import { + EuiBadge, + EuiBasicTable, + EuiButton, + EuiCallOut, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiPageHeader, + EuiSpacer, + type EuiBasicTableColumn, +} from '@elastic/eui'; +import { useService } from '@kbn/core-di-browser'; +import useMountedState from 'react-use/lib/useMountedState'; +import { useHistory } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import type { RuleListItem } from '../services/rules_api'; +import { RulesApi } from '../services/rules_api'; + +const getErrorMessage = (error: unknown) => { + if (error instanceof Error) { + return error.message; + } + return String(error); +}; + +export const RulesListPage = () => { + const history = useHistory(); + const rulesApi = useService(RulesApi); + const isMounted = useMountedState(); + const [rules, setRules] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadRules = async () => { + setIsLoading(true); + setError(null); + + try { + const result = await rulesApi.listRules({ page: 1, perPage: 100 }); + if (isMounted()) { + setRules(result.items); + } + } catch (err) { + if (isMounted()) { + setError(getErrorMessage(err)); + } + } finally { + if (isMounted()) { + setIsLoading(false); + } + } + }; + + loadRules(); + }, [rulesApi, isMounted]); + + const columns: Array> = useMemo( + () => [ + { + field: 'name', + name: ( + + ), + }, + { + field: 'id', + name: ( + + ), + }, + { + field: 'enabled', + name: ( + + ), + render: (enabled?: boolean) => + enabled ? ( + + ) : ( + + ), + }, + { + field: 'schedule', + name: ( + + ), + render: (schedule?: RuleListItem['schedule']) => + schedule?.custom ? ( + schedule.custom + ) : ( + + ), + }, + { + field: 'query', + name: ( + + ), + render: (query?: string) => + query ? ( + + {query} + + ) : ( + + ), + }, + { + field: 'tags', + name: ( + + ), + render: (tags?: string[]) => + tags && tags.length ? ( + + {tags.map((tag) => ( + + {tag} + + ))} + + ) : ( + + ), + }, + { + name: ( + + ), + actions: [ + { + name: i18n.translate('xpack.alertingV2.rulesList.action.edit', { + defaultMessage: 'Edit', + }), + description: i18n.translate('xpack.alertingV2.rulesList.action.editDescription', { + defaultMessage: 'Edit rule', + }), + icon: 'pencil', + type: 'icon', + onClick: (rule) => history.push(`/edit/${rule.id}`), + }, + ], + }, + ], + [history] + ); + + return ( + <> + + } + rightSideItems={[ + history.push('/create')}> + + , + ]} + /> + + {error ? ( + <> + + } + color="danger" + iconType="error" + > + {error} + + + + ) : null} + + + ); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/constants.ts b/x-pack/platform/plugins/shared/alerting_v2/public/constants.ts new file mode 100644 index 0000000000000..431a3b9cd9089 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/constants.ts @@ -0,0 +1,10 @@ +/* + * 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 const ALERTING_V2_APP_ID = 'alerting_v2'; +export const ALERTING_V2_APP_ROUTE = '/alerting_v2'; +export const INTERNAL_ALERTING_V2_RULE_API_PATH = '/internal/alerting/v2/rule' as const; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/index.ts b/x-pack/platform/plugins/shared/alerting_v2/public/index.ts new file mode 100644 index 0000000000000..b250c963f1cf4 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/index.ts @@ -0,0 +1,36 @@ +/* + * 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 { ContainerModule } from 'inversify'; +import { OnSetup, PluginSetup } from '@kbn/core-di'; +import { CoreSetup } from '@kbn/core-di-browser'; +import type { ManagementSetup } from '@kbn/management-plugin/public'; +import { mountAlertingV2App } from './main'; +import { ALERTING_V2_APP_ID } from './constants'; +import { RulesApi } from './services/rules_api'; + +export const module = new ContainerModule(({ bind }) => { + bind(RulesApi).toSelf().inSingletonScope(); + bind(OnSetup).toConstantValue((container) => { + const getStartServices = container.get(CoreSetup('getStartServices')); + + if (!container.isBound(PluginSetup('management'))) { + return; + } + + const management = container.get(PluginSetup('management')) as ManagementSetup; + management.sections.section.insightsAndAlerting.registerApp({ + id: ALERTING_V2_APP_ID, + title: 'Rules V2', + order: 1, + async mount(params) { + const [coreStart] = await getStartServices(); + return mountAlertingV2App({ params, container: coreStart.injection.getContainer() }); + }, + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/main.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/main.tsx new file mode 100644 index 0000000000000..ac7fe03993132 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/main.tsx @@ -0,0 +1,66 @@ +/* + * 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 ReactDOM from 'react-dom'; +import { inject, injectable } from 'inversify'; +import type { Container } from 'inversify'; +import type { + AppDeepLinkLocations, + AppMountParameters, + AppUnmount, +} from '@kbn/core-application-browser'; +import { DEFAULT_APP_CATEGORIES } from '@kbn/core/public'; +import type { CoreDiServiceStart } from '@kbn/core-di'; +import { ApplicationParameters, Context, CoreStart } from '@kbn/core-di-browser'; +import { Router } from '@kbn/shared-ux-router'; +import { I18nProvider } from '@kbn/i18n-react'; +import { ALERTING_V2_APP_ID, ALERTING_V2_APP_ROUTE } from './constants'; +import { App } from './components/app'; + +@injectable() +export class AlertingV2App { + public static id = ALERTING_V2_APP_ID; + public static title = 'Rules V2'; + public static appRoute = ALERTING_V2_APP_ROUTE; + public static visibleIn: AppDeepLinkLocations[] = ['sideNav']; + public static category = DEFAULT_APP_CATEGORIES.management; + + constructor( + @inject(ApplicationParameters) private readonly params: AppMountParameters, + @inject(CoreStart('injection')) private readonly di: CoreDiServiceStart + ) {} + + public mount(): AppUnmount { + return mountAlertingV2App({ params: this.params, container: this.di.getContainer() }); + } +} + +type AlertingV2MountParams = Pick; + +export const mountAlertingV2App = ({ + params, + container, +}: { + params: AlertingV2MountParams; + container: Container; +}): AppUnmount => { + const { element, history } = params; + + ReactDOM.render( + + + + + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/services/rules_api.ts b/x-pack/platform/plugins/shared/alerting_v2/public/services/rules_api.ts new file mode 100644 index 0000000000000..2fdacd9188600 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/services/rules_api.ts @@ -0,0 +1,61 @@ +/* + * 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 { inject, injectable } from 'inversify'; +import type { HttpStart } from '@kbn/core/public'; +import { CoreStart } from '@kbn/core-di-browser'; +import { INTERNAL_ALERTING_V2_RULE_API_PATH } from '../constants'; +import type { CreateRuleData } from '../../common/types'; + +export interface RuleListItem { + id: string; + name: string; + enabled?: boolean; + query?: string; + schedule?: { custom?: string }; + tags?: string[]; +} + +export interface RuleDetails extends RuleListItem { + timeField?: string; + lookbackWindow?: string; + groupingKey?: string[]; +} + +export interface FindRulesResponse { + items: RuleListItem[]; + total: number; + page: number; + perPage: number; +} + +@injectable() +export class RulesApi { + constructor(@inject(CoreStart('http')) private readonly http: HttpStart) {} + + public async listRules(params: { page?: number; perPage?: number }) { + return this.http.get(INTERNAL_ALERTING_V2_RULE_API_PATH, { + query: { page: params.page, perPage: params.perPage }, + }); + } + + public async createRule(payload: CreateRuleData) { + return this.http.post(INTERNAL_ALERTING_V2_RULE_API_PATH, { + body: JSON.stringify(payload), + }); + } + + public async getRule(id: string) { + return this.http.get(`${INTERNAL_ALERTING_V2_RULE_API_PATH}/${id}`); + } + + public async updateRule(id: string, payload: CreateRuleData) { + return this.http.patch(`${INTERNAL_ALERTING_V2_RULE_API_PATH}/${id}`, { + body: JSON.stringify(payload), + }); + } +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/duration.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/duration.ts index 53b674492b316..27a7206793132 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/duration.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/duration.ts @@ -5,13 +5,11 @@ * 2.0. */ +import { validateDuration } from '../../common/validation'; + const DURATION_RE = /^(\d+)(ms|s|m|h|d|w)$/; -export function validateDuration(value: string): string | void { - if (!DURATION_RE.test(value)) { - return `Invalid duration "${value}". Expected format like "5m", "1h", "30s", "250ms"`; - } -} +export { validateDuration }; export function parseDurationToMs(value: string): number { const match = value.match(DURATION_RE); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts index 71bbd5ab1cd8d..8083a8ba3dfc3 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts @@ -54,24 +54,23 @@ export class RulesClient { public async createRule(params: CreateRuleParams): Promise { const { spaceId } = this.getSpaceContext(); - try { - createRuleDataSchema.validate(params.data); - } catch (error) { - throw Boom.badRequest(`Error validating create rule data - ${(error as Error).message}`); + const parsed = createRuleDataSchema.safeParse(params.data); + if (!parsed.success) { + throw Boom.badRequest(`Error validating create rule data - ${parsed.error.message}`); } const username = await this.getUserName(); const nowIso = new Date().toISOString(); const ruleAttributes: RuleSavedObjectAttributes = { - name: params.data.name, - tags: params.data.tags ?? [], - schedule: params.data.schedule, - enabled: params.data.enabled, - query: params.data.query, - timeField: params.data.timeField, - lookbackWindow: params.data.lookbackWindow, - groupingKey: params.data.groupingKey ?? [], + name: parsed.data.name, + tags: parsed.data.tags, + schedule: parsed.data.schedule, + enabled: parsed.data.enabled, + query: parsed.data.query, + timeField: parsed.data.timeField, + lookbackWindow: parsed.data.lookbackWindow, + groupingKey: parsed.data.groupingKey, createdBy: username, createdAt: nowIso, updatedBy: username, @@ -122,10 +121,9 @@ export class RulesClient { }): Promise { const { spaceId } = this.getSpaceContext(); - try { - updateRuleDataSchema.validate(data); - } catch (error) { - throw Boom.badRequest(`Error validating update rule data - ${(error as Error).message}`); + const parsed = updateRuleDataSchema.safeParse(data); + if (!parsed.success) { + throw Boom.badRequest(`Error validating update rule data - ${parsed.error.message}`); } const username = await this.getUserName(); @@ -145,11 +143,12 @@ export class RulesClient { } const wasEnabled = Boolean(existingAttrs.enabled); - const willBeEnabled = data.enabled !== undefined ? Boolean(data.enabled) : wasEnabled; + const willBeEnabled = + parsed.data.enabled !== undefined ? Boolean(parsed.data.enabled) : wasEnabled; const nextAttrs: RuleSavedObjectAttributes = { ...existingAttrs, - ...data, + ...parsed.data, updatedBy: username, updatedAt: nowIso, }; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/schemas/create_rule_data_schema.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/schemas/create_rule_data_schema.ts index 6e7e5673855f5..8ad9beec85913 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/schemas/create_rule_data_schema.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/schemas/create_rule_data_schema.ts @@ -5,22 +5,4 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; -import { validateDuration } from '../../duration'; -import { validateEsqlQuery } from '../validators'; - -export const createRuleDataSchema = schema.object( - { - name: schema.string({ minLength: 1, maxLength: 64 }), - tags: schema.arrayOf(schema.string({ maxLength: 64 }), { defaultValue: [], maxSize: 100 }), - schedule: schema.object({ - custom: schema.string({ validate: validateDuration }), - }), - enabled: schema.boolean({ defaultValue: true }), - query: schema.string({ minLength: 1, maxLength: 10000, validate: validateEsqlQuery }), - timeField: schema.string({ minLength: 1, maxLength: 128, defaultValue: '@timestamp' }), - lookbackWindow: schema.string({ validate: validateDuration }), - groupingKey: schema.arrayOf(schema.string(), { defaultValue: [], maxSize: 16 }), - }, - { unknowns: 'ignore' } -); +export { createRuleDataSchema } from '../../../../common/schemas/create_rule_data_schema'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/schemas/update_rule_data_schema.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/schemas/update_rule_data_schema.ts index d842b3015afcb..e53f6ba24d08c 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/schemas/update_rule_data_schema.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/schemas/update_rule_data_schema.ts @@ -5,24 +5,4 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; -import { validateDuration } from '../../duration'; -import { validateEsqlQuery } from '../validators'; - -export const updateRuleDataSchema = schema.object( - { - name: schema.maybe(schema.string({ minLength: 1 })), - tags: schema.maybe(schema.arrayOf(schema.string(), { defaultValue: [] })), - schedule: schema.maybe( - schema.object({ - custom: schema.string({ validate: validateDuration }), - }) - ), - enabled: schema.maybe(schema.boolean()), - query: schema.maybe(schema.string({ minLength: 1, validate: validateEsqlQuery })), - timeField: schema.maybe(schema.string({ minLength: 1 })), - lookbackWindow: schema.maybe(schema.string({ validate: validateDuration })), - groupingKey: schema.maybe(schema.arrayOf(schema.string(), { defaultValue: [] })), - }, - { unknowns: 'ignore' } -); +export { createRuleDataSchema as updateRuleDataSchema } from '../../../../common/schemas/create_rule_data_schema'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/types.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/types.ts index 91b155b1ecb69..d02c8ccd557e4 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/types.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/types.ts @@ -5,10 +5,11 @@ * 2.0. */ -import type { TypeOf } from '@kbn/config-schema'; -import type { createRuleDataSchema, updateRuleDataSchema } from './schemas'; +import type { TypeOf as ZodTypeOf } from '@kbn/zod'; +import type { CreateRuleData } from '../../../common/types'; +import type { updateRuleDataSchema } from './schemas'; -export type CreateRuleData = TypeOf; +export type { CreateRuleData }; export interface CreateRuleParams { data: CreateRuleData; @@ -23,7 +24,7 @@ export interface RuleResponse extends CreateRuleData { updatedAt: string; } -export type UpdateRuleData = TypeOf; +export type UpdateRuleData = ZodTypeOf; export interface FindRulesParams { page?: number; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/validators.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/validators.ts index 0225bcd2f0c52..3bb86ec94c6f7 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/validators.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/validators.ts @@ -5,11 +5,4 @@ * 2.0. */ -import { Parser } from '@kbn/esql-language'; - -export const validateEsqlQuery = (query: string): string | void => { - const errors = Parser.parseErrors(query); - if (errors.length > 0) { - return `Invalid ES|QL query: ${errors[0].message}`; - } -}; +export { validateEsqlQuery } from '../../../common/validation'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/create_rule_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/create_rule_route.ts index 93bdc71b2f5ae..8d43252481d86 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/create_rule_route.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/create_rule_route.ts @@ -19,6 +19,7 @@ import { createRuleDataSchema, type CreateRuleData } from '../lib/rules_client'; import { RulesClient } from '../lib/rules_client'; import { ALERTING_V2_API_PRIVILEGES } from '../lib/security/privileges'; import { INTERNAL_ALERTING_V2_RULE_API_PATH } from './constants'; +import { buildRouteValidationWithZod } from './route_validation'; const createRuleParamsSchema = schema.object({ id: schema.maybe(schema.string()), @@ -36,7 +37,7 @@ export class CreateRuleRoute implements RouteHandler { static options = { access: 'internal' } as const; static validate = { request: { - body: createRuleDataSchema, + body: buildRouteValidationWithZod(createRuleDataSchema), params: createRuleParamsSchema, }, } as const; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/route_validation.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/route_validation.ts new file mode 100644 index 0000000000000..2a9c3f3e3da1a --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/route_validation.ts @@ -0,0 +1,20 @@ +/* + * 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 type { RouteValidationFunction, RouteValidationResultFactory } from '@kbn/core/server'; +import { stringifyZodError } from '@kbn/zod-helpers'; +import type { TypeOf, ZodType } from '@kbn/zod'; + +export const buildRouteValidationWithZod = + >(schema: T): RouteValidationFunction => + (inputValue: unknown, validationResult: RouteValidationResultFactory) => { + const decoded = schema.safeParse(inputValue); + if (decoded.success) { + return validationResult.ok(decoded.data); + } + return validationResult.badRequest(stringifyZodError(decoded.error)); + }; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/update_rule_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/update_rule_route.ts index 4d470c7e28c0a..16e641e59f941 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/update_rule_route.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/update_rule_route.ts @@ -17,6 +17,7 @@ import { updateRuleDataSchema, type UpdateRuleData } from '../lib/rules_client'; import { RulesClient } from '../lib/rules_client/rules_client'; import { ALERTING_V2_API_PRIVILEGES } from '../lib/security/privileges'; import { INTERNAL_ALERTING_V2_RULE_API_PATH } from './constants'; +import { buildRouteValidationWithZod } from './route_validation'; const updateRuleParamsSchema = schema.object({ id: schema.string(), @@ -34,7 +35,7 @@ export class UpdateRuleRoute { static options = { access: 'internal' } as const; static validate = { request: { - body: updateRuleDataSchema, + body: buildRouteValidationWithZod(updateRuleDataSchema), params: updateRuleParamsSchema, }, } as const; diff --git a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json index 2c7c4cca891ef..b87eabf9c0562 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json +++ b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json @@ -25,10 +25,18 @@ "@kbn/spaces-utils", "@kbn/search-types", "@kbn/core-di", + "@kbn/core-di-browser", "@kbn/core-di-server", "@kbn/esql-utils", "@kbn/response-ops-retry-service", "@kbn/esql-language", + "@kbn/core-application-browser", + "@kbn/shared-ux-router", + "@kbn/management-plugin", + "@kbn/code-editor", + "@kbn/i18n-react", + "@kbn/monaco", + "@kbn/esql-types", "@kbn/core-http-server-mocks", "@kbn/core-security-common", "@kbn/core-elasticsearch-server-mocks", From 52d8c0b7a52886efb9842abd33748d7806f941ff Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:08:19 +0000 Subject: [PATCH 2/8] Changes from node scripts/lint_ts_projects --fix --- x-pack/platform/plugins/shared/alerting_v2/tsconfig.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json index b87eabf9c0562..bfd345287128e 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json +++ b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json @@ -44,7 +44,9 @@ "@kbn/core-elasticsearch-client-server-mocks", "@kbn/es-errors", "@kbn/zod", - "@kbn/core-di-internal" + "@kbn/core-di-internal", + "@kbn/i18n", + "@kbn/zod-helpers" ], "exclude": [ "target/**/*" From ae23de185e15d6126c8a4d257f565a81e68193dc Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Thu, 22 Jan 2026 15:10:03 +0000 Subject: [PATCH 3/8] support multi line query --- .../public/components/create_rule_page.tsx | 587 ++++++++++++------ 1 file changed, 399 insertions(+), 188 deletions(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx index 6452914a27726..356ed861242d7 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx @@ -32,11 +32,11 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { ESQLCallbacks } from '@kbn/esql-types'; import { getESQLSources, getEsqlColumns } from '@kbn/esql-utils'; import { suggest } from '@kbn/esql-language'; -import { zodToJsonSchema } from 'zod-to-json-schema'; import { FormattedMessage } from '@kbn/i18n-react'; import { dump, load } from 'js-yaml'; import { useHistory, useParams } from 'react-router-dom'; import YAML, { LineCounter } from 'yaml'; +import { zodToJsonSchema } from 'zod-to-json-schema'; import { createRuleDataSchema } from '../../common/schemas/create_rule_data_schema'; import type { CreateRuleData } from '../../common/types'; import { RulesApi } from '../services/rules_api'; @@ -86,78 +86,212 @@ interface QueryContext { queryOffset: number; } -const createRuleJsonSchema = zodToJsonSchema(createRuleDataSchema, { - name: 'CreateRuleData', - $refStrategy: 'none', -}); +// Schema property info extracted from JSON schema +interface SchemaPropertyInfo { + key: string; + description?: string; + type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'unknown'; + isEnum?: boolean; + enumValues?: string[]; +} -const resolveSchemaRef = (schema: any, ref?: string) => { - if (!ref || !schema || typeof schema !== 'object') { - return schema; - } - if (!ref.startsWith('#/')) { - return schema; - } - const path = ref - .slice(2) - .split('/') - .map((segment) => decodeURIComponent(segment)); - let current = schema; - for (const segment of path) { - if (!current || typeof current !== 'object') { - return schema; - } - current = current[segment]; +// JSON Schema types +interface JsonSchema { + type?: string | string[]; + properties?: Record; + items?: JsonSchema; + description?: string; + enum?: Array; + anyOf?: JsonSchema[]; + oneOf?: JsonSchema[]; + allOf?: JsonSchema[]; + $ref?: string; + definitions?: Record; + $defs?: Record; + default?: unknown; +} + +// Convert Zod schema to JSON Schema (cached) +let cachedJsonSchema: JsonSchema | null = null; +const getJsonSchema = (): JsonSchema => { + if (!cachedJsonSchema) { + cachedJsonSchema = zodToJsonSchema(createRuleDataSchema, { + $refStrategy: 'none', + errorMessages: true, + }) as JsonSchema; } - return current ?? schema; + return cachedJsonSchema; }; -const getRootSchema = () => { - const definitionsRoot = createRuleJsonSchema.definitions?.CreateRuleData; - if (definitionsRoot) { - return definitionsRoot; +// Resolve anyOf/oneOf to get the actual schema (handles optionals, unions, etc.) +const resolveSchema = (schema: JsonSchema): JsonSchema => { + if (schema.anyOf) { + // Find non-null type in anyOf (for optionals) + const nonNull = schema.anyOf.find( + (s) => s.type !== 'null' && !(Array.isArray(s.type) && s.type.includes('null')) + ); + if (nonNull) return resolveSchema(nonNull); + return schema.anyOf[0] ? resolveSchema(schema.anyOf[0]) : schema; + } + if (schema.oneOf) { + const nonNull = schema.oneOf.find( + (s) => s.type !== 'null' && !(Array.isArray(s.type) && s.type.includes('null')) + ); + if (nonNull) return resolveSchema(nonNull); + return schema.oneOf[0] ? resolveSchema(schema.oneOf[0]) : schema; } - const schemaWithRef = createRuleJsonSchema as { $ref?: string }; - if (schemaWithRef.$ref) { - return resolveSchemaRef(createRuleJsonSchema, schemaWithRef.$ref); + if (schema.allOf && schema.allOf.length === 1) { + return resolveSchema(schema.allOf[0]); } - return createRuleJsonSchema; + return schema; }; -const getSchemaNode = (path: Array) => { - let current: any = getRootSchema(); +// Get JSON schema node at path +const getSchemaNode = (path: Array): JsonSchema | undefined => { + let current: JsonSchema = getJsonSchema(); + for (const segment of path) { - if (!current || typeof current !== 'object') { + current = resolveSchema(current); + + if (typeof segment === 'number') { + // Array index + if (current.items) { + current = current.items; + continue; + } return undefined; } - if (current.$ref) { - current = resolveSchemaRef(createRuleJsonSchema, current.$ref); - } - if (typeof segment === 'number') { - current = current.items; + + // Object property + if (current.properties && segment in current.properties) { + current = current.properties[segment]; continue; } - current = current.properties?.[segment]; - } - if (current?.$ref) { - return resolveSchemaRef(createRuleJsonSchema, current.$ref); + + return undefined; } + return current; }; -const getSchemaDescription = (path: string[]) => { - return getSchemaNode(path)?.description as string | undefined; +// Get type from JSON schema +const getSchemaType = (schema: JsonSchema): SchemaPropertyInfo['type'] => { + const resolved = resolveSchema(schema); + const type = Array.isArray(resolved.type) ? resolved.type[0] : resolved.type; + + switch (type) { + case 'string': + return 'string'; + case 'number': + case 'integer': + return 'number'; + case 'boolean': + return 'boolean'; + case 'array': + return 'array'; + case 'object': + return 'object'; + default: + return 'unknown'; + } }; -const getSchemaProperties = (path: string[]) => { +// Get properties from JSON schema at path +const getSchemaProperties = (path: string[]): SchemaPropertyInfo[] => { const node = getSchemaNode(path); - if (!node || typeof node !== 'object') { - return []; + if (!node) return []; + + const resolved = resolveSchema(node); + if (!resolved.properties) return []; + + return Object.entries(resolved.properties).map(([key, propSchema]) => { + const resolvedProp = resolveSchema(propSchema); + const type = getSchemaType(propSchema); + const isEnum = Boolean(resolvedProp.enum); + const enumValues = isEnum ? (resolvedProp.enum as string[]) : undefined; + + return { + key, + description: propSchema.description ?? resolvedProp.description, + type, + isEnum, + enumValues, + }; + }); +}; + +// Get schema description at path +const getSchemaDescription = (path: string[]): string | undefined => { + const node = getSchemaNode(path); + if (!node) return undefined; + const resolved = resolveSchema(node); + return node.description ?? resolved.description; +}; + +// Get schema type info at path (for value completions) +const getSchemaTypeInfo = ( + path: string[] +): { type: string; isBoolean: boolean; enumValues?: string[] } | undefined => { + const node = getSchemaNode(path); + if (!node) return undefined; + + const resolved = resolveSchema(node); + const type = Array.isArray(resolved.type) ? resolved.type[0] : resolved.type; + + if (type === 'boolean') { + return { type: 'boolean', isBoolean: true }; + } + if (resolved.enum) { + return { type: 'enum', isBoolean: false, enumValues: resolved.enum as string[] }; } - return Object.entries(node.properties ?? {}).map(([key, value]) => ({ - key, - schema: value as { description?: string; type?: string }, - })); + + return { type: type ?? 'unknown', isBoolean: false }; +}; + +// Get existing keys from YAML text at a given indent level +const getExistingYamlKeys = (text: string, parentPath: string[]): Set => { + const keys = new Set(); + const lines = text.split('\n'); + + if (parentPath.length === 0) { + // Root level - get all top-level keys + for (const line of lines) { + const match = /^([A-Za-z0-9_]+)\s*:/.exec(line); + if (match) { + keys.add(match[1]); + } + } + } else { + // Nested level - find parent and get its children + let parentIndent = -1; + let inParent = false; + + for (const line of lines) { + const keyMatch = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(line); + + if (keyMatch) { + const key = keyMatch[2]; + const keyIndent = keyMatch[1].length; + + if (!inParent && key === parentPath[parentPath.length - 1] && keyIndent === 0) { + inParent = true; + parentIndent = keyIndent; + continue; + } + + if (inParent) { + if (keyIndent <= parentIndent && line.trim() !== '') { + break; // Exited parent scope + } + if (keyIndent === parentIndent + 2) { + keys.add(key); + } + } + } + } + } + + return keys; }; const getCompletionContext = (text: string, position: monaco.Position) => { @@ -494,27 +628,38 @@ const ensureAlertingYamlLanguage = () => { }; } - const queryMatch = /^(\s*)query:\s*(.*)$/.exec(line); + const queryMatch = /^(\s*)(query)(:)\s*(.*)$/.exec(line); if (!queryMatch) { return { tokens: tokenizeYamlLine(line), endState: state }; } const baseIndent = queryMatch[1].length; - const value = queryMatch[2] ?? ''; - const valueStartIndex = line.indexOf(value); - const valueStartOffset = Math.max(valueStartIndex, 0); + const keyStart = baseIndent; + const keyEnd = keyStart + queryMatch[2].length; + const colonPos = keyEnd; + const value = queryMatch[4] ?? ''; + const valueStartIndex = colonPos + 1 + (line.slice(colonPos + 1).length - value.length); const trimmedValue = value.trim(); + // Build tokens: key gets 'type.yaml' (same as other YAML keys), colon gets punctuation + const baseTokens: monaco.languages.IToken[] = [ + { startIndex: 0, scopes: 'source.yaml' }, + { startIndex: keyStart, scopes: 'type.yaml' }, // "query" highlighted as YAML key + { startIndex: colonPos, scopes: 'operators.yaml' }, // ":" highlighted as operator + ]; + if (trimmedValue.startsWith('>') || trimmedValue.startsWith('|')) { + // Block scalar indicator - tokenize as YAML + const yamlTokens = tokenizeYamlLine(line); return { - tokens: tokenizeYamlLine(line), + tokens: yamlTokens, endState: new AlertingYamlState('pending', baseIndent, 0), }; } if (trimmedValue.length > 0) { const rawValue = value.trimStart(); - const rawValueOffset = valueStartOffset + (value.length - rawValue.length); + const rawValueOffset = valueStartIndex + (value.length - rawValue.length); const quote = rawValue.startsWith('"') || rawValue.startsWith("'") ? rawValue[0] : null; const closingIndex = quote ? rawValue.lastIndexOf(quote) : rawValue.length; const queryText = rawValue.slice( @@ -522,197 +667,258 @@ const ensureAlertingYamlLanguage = () => { closingIndex > 0 ? closingIndex : undefined ); const queryOffset = rawValueOffset + (quote ? 1 : 0); - const tokens = [ - { startIndex: 0, scopes: 'source.yaml' }, - ...tokenizeEsqlLine(queryText, queryOffset), - ]; + const tokens = [...baseTokens, ...tokenizeEsqlLine(queryText, queryOffset)]; return { tokens, endState: new AlertingYamlState('inline', baseIndent, 0) }; } - return { tokens: tokenizeYamlLine(line), endState: state }; + return { tokens: baseTokens, endState: new AlertingYamlState('pending', baseIndent, 0) }; }, }); }; ensureAlertingYamlLanguage(); +/** + * Find the ES|QL query context at the cursor position. + * Handles inline queries, block scalar queries (| or >), and multi-line continuation. + */ const findYamlQueryContext = (text: string, cursorOffset: number): QueryContext | null => { const lines = text.split('\n'); + + // Build line start offsets and find cursor line const lineStartOffsets: number[] = []; let runningOffset = 0; let cursorLine = 0; for (let i = 0; i < lines.length; i++) { lineStartOffsets.push(runningOffset); - const nextOffset = runningOffset + lines[i].length + 1; - if (cursorOffset < nextOffset) { + if (cursorOffset >= runningOffset && cursorOffset <= runningOffset + lines[i].length) { cursorLine = i; } - runningOffset = nextOffset; + runningOffset += lines[i].length + 1; // +1 for newline } - for (let i = cursorLine; i >= 0; i--) { - const line = lines[i]; - const match = /^(\s*)query:\s*(.*)$/.exec(line); - if (!match) { - continue; + // Search backwards for the `query:` key + for (let queryLineIdx = cursorLine; queryLineIdx >= 0; queryLineIdx--) { + const line = lines[queryLineIdx]; + const queryMatch = /^(\s*)query:\s*(.*)$/.exec(line); + if (!queryMatch) continue; + + const baseIndent = queryMatch[1].length; + const afterColon = queryMatch[2] ?? ''; + const trimmedAfterColon = afterColon.trim(); + + // Case 1: Block scalar (| or >) + if (trimmedAfterColon.startsWith('|') || trimmedAfterColon.startsWith('>')) { + // Find the block indent from first content line + let blockIndent = 0; + for (let j = queryLineIdx + 1; j < lines.length; j++) { + if (lines[j].trim() === '') continue; + const lineIndent = lines[j].match(/^\s*/)?.[0].length ?? 0; + if (lineIndent > baseIndent) { + blockIndent = lineIndent; + } + break; + } + + // If no content yet, use cursor indent or default + if (blockIndent === 0) { + if (cursorLine <= queryLineIdx) return null; + const cursorIndent = lines[cursorLine].match(/^\s*/)?.[0].length ?? 0; + blockIndent = Math.max(baseIndent + 2, cursorIndent); + } + + // Find end of block + let endLine = queryLineIdx + 1; + while (endLine < lines.length) { + const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; + if (lines[endLine].trim() !== '' && lineIndent < blockIndent) break; + endLine++; + } + + // Check if cursor is in the block + if (cursorLine <= queryLineIdx || cursorLine >= endLine) return null; + + // Extract query lines + const queryLines = lines + .slice(queryLineIdx + 1, endLine) + .map((lineText) => (lineText.length >= blockIndent ? lineText.slice(blockIndent) : '')); + const queryText = queryLines.join('\n'); + + // Calculate offset within query + const cursorLineInQuery = cursorLine - (queryLineIdx + 1); + const cursorColInLine = Math.max( + 0, + cursorOffset - lineStartOffsets[cursorLine] - blockIndent + ); + const offsetBeforeCursorLine = queryLines + .slice(0, cursorLineInQuery) + .reduce((acc, l) => acc + l.length + 1, 0); + + return { + queryText, + queryOffset: Math.max( + 0, + Math.min(queryText.length, offsetBeforeCursorLine + cursorColInLine) + ), + }; + } + + // Case 2a: Empty value but cursor is on the query line (e.g., "query: " with cursor after colon) + if (trimmedAfterColon.length === 0 && cursorLine === queryLineIdx) { + // Find where the cursor would be relative to where a value would start + const colonIdx = line.indexOf(':'); + const valueStartCol = colonIdx + 1; + const valueStartOffset = lineStartOffsets[queryLineIdx] + valueStartCol; + + // Cursor must be at or after the colon + if (cursorOffset >= valueStartOffset) { + return { + queryText: '', + queryOffset: 0, + }; + } + return null; } - const baseIndent = match[1].length; - const value = match[2] ?? ''; - const valueStartIndex = line.indexOf(value); - const valueStartOffset = lineStartOffsets[i] + Math.max(valueStartIndex, 0); - - if (value.trim().length > 0 && !/^[>|]/.test(value.trim())) { - const rawValue = value.trimStart(); - const rawValueOffset = valueStartOffset + (value.length - rawValue.length); - const quote = rawValue.startsWith('"') || rawValue.startsWith("'") ? rawValue[0] : null; - const closingIndex = quote ? rawValue.lastIndexOf(quote) : rawValue.length; - const queryStartOffset = rawValueOffset + (quote ? 1 : 0); + // Case 2b: Inline value (possibly with continuation lines) + if (trimmedAfterColon.length > 0) { + // afterColon is already trimmed of leading whitespace by the regex's \s* + // So we calculate position by finding where afterColon starts in the line + const valueStartCol = line.length - afterColon.length; + const valueStartOffset = lineStartOffsets[queryLineIdx] + valueStartCol; + + // Handle quoted strings + const quote = afterColon.startsWith('"') || afterColon.startsWith("'") ? afterColon[0] : null; + const closingQuoteIdx = quote ? afterColon.lastIndexOf(quote) : -1; + const queryStartOffset = valueStartOffset + (quote ? 1 : 0); const queryEndOffset = - closingIndex > 0 ? rawValueOffset + closingIndex : rawValueOffset + rawValue.length; + closingQuoteIdx > 0 + ? valueStartOffset + closingQuoteIdx + : valueStartOffset + afterColon.length; + // Check for multi-line continuation let continuationIndent = 0; - for (let j = i + 1; j < lines.length; j++) { - const trimmedLine = lines[j].trim(); - if (trimmedLine === '') { - continue; - } - const indent = lines[j].match(/^\s*/)?.[0].length ?? 0; - if (indent > baseIndent) { - continuationIndent = indent; + for (let j = queryLineIdx + 1; j < lines.length; j++) { + if (lines[j].trim() === '') continue; + const lineIndent = lines[j].match(/^\s*/)?.[0].length ?? 0; + if (lineIndent > baseIndent) { + continuationIndent = lineIndent; } break; } + // Single line query (no continuation) if (continuationIndent === 0) { - if (cursorOffset < queryStartOffset || cursorOffset > queryEndOffset) { - return null; - } - const queryText = rawValue.slice( + if (cursorLine !== queryLineIdx) return null; + if (cursorOffset < queryStartOffset || cursorOffset > queryEndOffset) return null; + + const queryText = afterColon.slice( quote ? 1 : 0, - closingIndex > 0 ? closingIndex : undefined + closingQuoteIdx > 0 ? closingQuoteIdx : undefined ); - const queryOffset = Math.max( - 0, - Math.min(queryText.length, cursorOffset - queryStartOffset) - ); - return { queryText, queryOffset }; + return { + queryText, + queryOffset: Math.max(0, Math.min(queryText.length, cursorOffset - queryStartOffset)), + }; } - let endLine = i + 1; + // Multi-line with continuation + let endLine = queryLineIdx + 1; while (endLine < lines.length) { const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; - if (lines[endLine].trim() !== '' && lineIndent <= baseIndent) { - break; - } - endLine += 1; + if (lines[endLine].trim() !== '' && lineIndent <= baseIndent) break; + endLine++; } - const firstLineQueryText = rawValue.slice( + if (cursorLine < queryLineIdx || cursorLine >= endLine) return null; + + // Build query text + const firstLineText = afterColon.slice( quote ? 1 : 0, - closingIndex > 0 ? closingIndex : undefined + closingQuoteIdx > 0 ? closingQuoteIdx : undefined ); - const continuationLines = lines.slice(i + 1, endLine).map((lineText) => { + const continuationLines = lines.slice(queryLineIdx + 1, endLine).map((lineText) => { const lineIndent = lineText.match(/^\s*/)?.[0].length ?? 0; - if (lineIndent > baseIndent) { - const sliceIndent = Math.min(continuationIndent, lineIndent); - return lineText.slice(sliceIndent); - } - return ''; + return lineIndent >= continuationIndent ? lineText.slice(continuationIndent) : ''; }); - const queryText = [firstLineQueryText, ...continuationLines].join('\n'); - - if (cursorLine < i || cursorLine >= endLine) { - return null; - } + const queryText = [firstLineText, ...continuationLines].join('\n'); - if (cursorLine === i) { - if (cursorOffset < queryStartOffset) { - return null; - } - const queryOffset = Math.max( - 0, - Math.min(firstLineQueryText.length, cursorOffset - queryStartOffset) - ); - return { queryText, queryOffset }; + // Calculate offset + if (cursorLine === queryLineIdx) { + if (cursorOffset < queryStartOffset) return null; + return { + queryText, + queryOffset: Math.max(0, Math.min(firstLineText.length, cursorOffset - queryStartOffset)), + }; } - const cursorLineOffset = cursorOffset - lineStartOffsets[cursorLine]; - const cursorOffsetInQueryLine = Math.max(0, cursorLineOffset - continuationIndent); - const lineOffsetInQuery = - firstLineQueryText.length + + const cursorLineInQuery = cursorLine - (queryLineIdx + 1); + const cursorColInLine = Math.max( + 0, + cursorOffset - lineStartOffsets[cursorLine] - continuationIndent + ); + const offsetBeforeCursorLine = + firstLineText.length + 1 + - continuationLines - .slice(0, cursorLine - (i + 1)) - .reduce((acc, curr) => acc + curr.length + 1, 0) + - cursorOffsetInQueryLine; + continuationLines.slice(0, cursorLineInQuery).reduce((acc, l) => acc + l.length + 1, 0); return { queryText, - queryOffset: Math.max(0, Math.min(queryText.length, lineOffsetInQuery)), + queryOffset: Math.max( + 0, + Math.min(queryText.length, offsetBeforeCursorLine + cursorColInLine) + ), }; } - if (i + 1 >= lines.length) { - return null; - } + // Case 3: Empty value - check if cursor is on a continuation line + if (cursorLine <= queryLineIdx) return null; + // Find block indent from subsequent lines let blockIndent = 0; - let firstContentLine = -1; - for (let j = i + 1; j < lines.length; j++) { - const trimmedLine = lines[j].trim(); - if (trimmedLine === '') { - continue; - } - const indent = lines[j].match(/^\s*/)?.[0].length ?? 0; - if (indent > baseIndent) { - blockIndent = indent; - firstContentLine = j; + for (let j = queryLineIdx + 1; j < lines.length; j++) { + if (lines[j].trim() === '') continue; + const lineIndent = lines[j].match(/^\s*/)?.[0].length ?? 0; + if (lineIndent > baseIndent) { + blockIndent = lineIndent; } break; } if (blockIndent === 0) { - if (cursorLine <= i) { - return null; - } const cursorIndent = lines[cursorLine].match(/^\s*/)?.[0].length ?? 0; - blockIndent = Math.max(baseIndent + 1, cursorIndent); - firstContentLine = i + 1; + blockIndent = Math.max(baseIndent + 2, cursorIndent); } - let endLine = firstContentLine; + // Find end of block + let endLine = queryLineIdx + 1; while (endLine < lines.length) { const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; - if (lines[endLine].trim() !== '' && lineIndent < blockIndent) { - break; - } - endLine += 1; + if (lines[endLine].trim() !== '' && lineIndent < blockIndent) break; + endLine++; } - const queryLines = lines.slice(i + 1, endLine).map((lineText) => { - if (lineText.length >= blockIndent) { - return lineText.slice(blockIndent); - } - return ''; - }); + if (cursorLine >= endLine) return null; + // Extract query + const queryLines = lines + .slice(queryLineIdx + 1, endLine) + .map((lineText) => (lineText.length >= blockIndent ? lineText.slice(blockIndent) : '')); const queryText = queryLines.join('\n'); - if (cursorLine < i + 1 || cursorLine >= endLine) { - return null; - } - const cursorLineOffset = cursorOffset - lineStartOffsets[cursorLine]; - const cursorOffsetInQueryLine = Math.max(0, cursorLineOffset - blockIndent); - - const lineOffsetInQuery = - queryLines.slice(0, cursorLine - (i + 1)).reduce((acc, curr) => acc + curr.length + 1, 0) + - cursorOffsetInQueryLine; + const cursorLineInQuery = cursorLine - (queryLineIdx + 1); + const cursorColInLine = Math.max(0, cursorOffset - lineStartOffsets[cursorLine] - blockIndent); + const offsetBeforeCursorLine = queryLines + .slice(0, cursorLineInQuery) + .reduce((acc, l) => acc + l.length + 1, 0); return { queryText, - queryOffset: Math.max(0, Math.min(queryText.length, lineOffsetInQuery)), + queryOffset: Math.max( + 0, + Math.min(queryText.length, offsetBeforeCursorLine + cursorColInLine) + ), }; } @@ -815,11 +1021,11 @@ export const CreateRulePage = () => { } if (completionContext.isValuePosition && completionContext.currentKey) { - const schemaNode = getSchemaNode([ + const typeInfo = getSchemaTypeInfo([ ...completionContext.parentPath, completionContext.currentKey, - ]) as { type?: string; enum?: string[] } | undefined; - if (schemaNode?.type === 'boolean') { + ]); + if (typeInfo?.isBoolean) { return { suggestions: ['true', 'false'].map((value) => ({ label: value, @@ -829,9 +1035,9 @@ export const CreateRulePage = () => { })), }; } - if (schemaNode?.enum) { + if (typeInfo?.enumValues) { return { - suggestions: schemaNode.enum.map((value) => ({ + suggestions: typeInfo.enumValues.map((value) => ({ label: value, insertText: value, kind: monaco.languages.CompletionItemKind.Value, @@ -842,15 +1048,20 @@ export const CreateRulePage = () => { return { suggestions: [] }; } + // Get schema properties and filter out keys that are already present const properties = getSchemaProperties(completionContext.parentPath); + const existingKeys = getExistingYamlKeys(fullText, completionContext.parentPath); + return { - suggestions: properties.map(({ key, schema }) => ({ - label: key, - insertText: `${key}: `, - kind: monaco.languages.CompletionItemKind.Property, - documentation: schema.description ? { value: schema.description } : undefined, - range, - })), + suggestions: properties + .filter(({ key }) => !existingKeys.has(key)) + .map(({ key, description }) => ({ + label: key, + insertText: `${key}: `, + kind: monaco.languages.CompletionItemKind.Property, + documentation: description ? { value: description } : undefined, + range, + })), }; }, }), From 6a4faff470af7dbabdc773888fe4d7edb9139cb6 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Thu, 22 Jan 2026 15:22:15 +0000 Subject: [PATCH 4/8] yml editor component --- .../public/components/create_rule_page.tsx | 1047 +--------------- .../public/components/yaml_rule_editor.tsx | 1062 +++++++++++++++++ 2 files changed, 1068 insertions(+), 1041 deletions(-) create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor.tsx diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx index 356ed861242d7..055fb7e80f455 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { EuiButton, EuiButtonEmpty, @@ -17,29 +17,18 @@ import { EuiPageHeader, EuiSpacer, } from '@elastic/eui'; -import { CodeEditorField } from '@kbn/code-editor'; -import { - ESQL_AUTOCOMPLETE_TRIGGER_CHARS, - ESQL_LANG_ID, - ESQLLang, - monaco, - YamlLang, - YAML_LANG_ID, -} from '@kbn/monaco'; import { useService, CoreStart } from '@kbn/core-di-browser'; import { PluginStart } from '@kbn/core-di'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { ESQLCallbacks } from '@kbn/esql-types'; import { getESQLSources, getEsqlColumns } from '@kbn/esql-utils'; -import { suggest } from '@kbn/esql-language'; import { FormattedMessage } from '@kbn/i18n-react'; import { dump, load } from 'js-yaml'; import { useHistory, useParams } from 'react-router-dom'; -import YAML, { LineCounter } from 'yaml'; -import { zodToJsonSchema } from 'zod-to-json-schema'; import { createRuleDataSchema } from '../../common/schemas/create_rule_data_schema'; import type { CreateRuleData } from '../../common/types'; import { RulesApi } from '../services/rules_api'; +import { YamlRuleEditor } from './yaml_rule_editor'; const DEFAULT_RULE_YAML = `name: Example rule tags: [] @@ -81,886 +70,6 @@ const parseYaml = (value: string): Record | null => { } }; -interface QueryContext { - queryText: string; - queryOffset: number; -} - -// Schema property info extracted from JSON schema -interface SchemaPropertyInfo { - key: string; - description?: string; - type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'unknown'; - isEnum?: boolean; - enumValues?: string[]; -} - -// JSON Schema types -interface JsonSchema { - type?: string | string[]; - properties?: Record; - items?: JsonSchema; - description?: string; - enum?: Array; - anyOf?: JsonSchema[]; - oneOf?: JsonSchema[]; - allOf?: JsonSchema[]; - $ref?: string; - definitions?: Record; - $defs?: Record; - default?: unknown; -} - -// Convert Zod schema to JSON Schema (cached) -let cachedJsonSchema: JsonSchema | null = null; -const getJsonSchema = (): JsonSchema => { - if (!cachedJsonSchema) { - cachedJsonSchema = zodToJsonSchema(createRuleDataSchema, { - $refStrategy: 'none', - errorMessages: true, - }) as JsonSchema; - } - return cachedJsonSchema; -}; - -// Resolve anyOf/oneOf to get the actual schema (handles optionals, unions, etc.) -const resolveSchema = (schema: JsonSchema): JsonSchema => { - if (schema.anyOf) { - // Find non-null type in anyOf (for optionals) - const nonNull = schema.anyOf.find( - (s) => s.type !== 'null' && !(Array.isArray(s.type) && s.type.includes('null')) - ); - if (nonNull) return resolveSchema(nonNull); - return schema.anyOf[0] ? resolveSchema(schema.anyOf[0]) : schema; - } - if (schema.oneOf) { - const nonNull = schema.oneOf.find( - (s) => s.type !== 'null' && !(Array.isArray(s.type) && s.type.includes('null')) - ); - if (nonNull) return resolveSchema(nonNull); - return schema.oneOf[0] ? resolveSchema(schema.oneOf[0]) : schema; - } - if (schema.allOf && schema.allOf.length === 1) { - return resolveSchema(schema.allOf[0]); - } - return schema; -}; - -// Get JSON schema node at path -const getSchemaNode = (path: Array): JsonSchema | undefined => { - let current: JsonSchema = getJsonSchema(); - - for (const segment of path) { - current = resolveSchema(current); - - if (typeof segment === 'number') { - // Array index - if (current.items) { - current = current.items; - continue; - } - return undefined; - } - - // Object property - if (current.properties && segment in current.properties) { - current = current.properties[segment]; - continue; - } - - return undefined; - } - - return current; -}; - -// Get type from JSON schema -const getSchemaType = (schema: JsonSchema): SchemaPropertyInfo['type'] => { - const resolved = resolveSchema(schema); - const type = Array.isArray(resolved.type) ? resolved.type[0] : resolved.type; - - switch (type) { - case 'string': - return 'string'; - case 'number': - case 'integer': - return 'number'; - case 'boolean': - return 'boolean'; - case 'array': - return 'array'; - case 'object': - return 'object'; - default: - return 'unknown'; - } -}; - -// Get properties from JSON schema at path -const getSchemaProperties = (path: string[]): SchemaPropertyInfo[] => { - const node = getSchemaNode(path); - if (!node) return []; - - const resolved = resolveSchema(node); - if (!resolved.properties) return []; - - return Object.entries(resolved.properties).map(([key, propSchema]) => { - const resolvedProp = resolveSchema(propSchema); - const type = getSchemaType(propSchema); - const isEnum = Boolean(resolvedProp.enum); - const enumValues = isEnum ? (resolvedProp.enum as string[]) : undefined; - - return { - key, - description: propSchema.description ?? resolvedProp.description, - type, - isEnum, - enumValues, - }; - }); -}; - -// Get schema description at path -const getSchemaDescription = (path: string[]): string | undefined => { - const node = getSchemaNode(path); - if (!node) return undefined; - const resolved = resolveSchema(node); - return node.description ?? resolved.description; -}; - -// Get schema type info at path (for value completions) -const getSchemaTypeInfo = ( - path: string[] -): { type: string; isBoolean: boolean; enumValues?: string[] } | undefined => { - const node = getSchemaNode(path); - if (!node) return undefined; - - const resolved = resolveSchema(node); - const type = Array.isArray(resolved.type) ? resolved.type[0] : resolved.type; - - if (type === 'boolean') { - return { type: 'boolean', isBoolean: true }; - } - if (resolved.enum) { - return { type: 'enum', isBoolean: false, enumValues: resolved.enum as string[] }; - } - - return { type: type ?? 'unknown', isBoolean: false }; -}; - -// Get existing keys from YAML text at a given indent level -const getExistingYamlKeys = (text: string, parentPath: string[]): Set => { - const keys = new Set(); - const lines = text.split('\n'); - - if (parentPath.length === 0) { - // Root level - get all top-level keys - for (const line of lines) { - const match = /^([A-Za-z0-9_]+)\s*:/.exec(line); - if (match) { - keys.add(match[1]); - } - } - } else { - // Nested level - find parent and get its children - let parentIndent = -1; - let inParent = false; - - for (const line of lines) { - const keyMatch = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(line); - - if (keyMatch) { - const key = keyMatch[2]; - const keyIndent = keyMatch[1].length; - - if (!inParent && key === parentPath[parentPath.length - 1] && keyIndent === 0) { - inParent = true; - parentIndent = keyIndent; - continue; - } - - if (inParent) { - if (keyIndent <= parentIndent && line.trim() !== '') { - break; // Exited parent scope - } - if (keyIndent === parentIndent + 2) { - keys.add(key); - } - } - } - } - } - - return keys; -}; - -const getCompletionContext = (text: string, position: monaco.Position) => { - const lines = text.split('\n'); - const lineIndex = Math.max(0, position.lineNumber - 1); - const line = lines[lineIndex] ?? ''; - const indent = line.match(/^\s*/)?.[0].length ?? 0; - const keyMatch = /^(\s*)([A-Za-z0-9_]+)\s*:(.*)$/.exec(line); - - if (keyMatch) { - const keyIndent = keyMatch[1].length; - const key = keyMatch[2]; - const hasValue = keyMatch[3].trim().length > 0; - const isValuePosition = hasValue || position.column > keyMatch[0].indexOf(':') + 1; - const parentPath = keyIndent === 0 ? [] : getYamlPathAtPosition(text, position)?.slice(0, -1); - return { - parentPath: parentPath ?? [], - currentKey: key, - isValuePosition, - }; - } - - if (indent === 0) { - return { parentPath: [], currentKey: null, isValuePosition: false }; - } - - for (let i = lineIndex - 1; i >= 0; i--) { - const match = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(lines[i]); - if (!match) { - continue; - } - const parentIndent = match[1].length; - if (parentIndent < indent) { - return { parentPath: [match[2]], currentKey: null, isValuePosition: false }; - } - } - - return { parentPath: [], currentKey: null, isValuePosition: false }; -}; - -const findYamlNodeForPath = (doc: YAML.Document, path: Array) => { - let node: YAML.Node | null | undefined = doc.contents; - let lastPair: YAML.Pair | null = null; - - for (const segment of path) { - if (YAML.isMap(node)) { - const pair = node.items.find( - (item) => YAML.isScalar(item.key) && item.key.value === segment - ) as YAML.Pair | undefined; - if (!pair) { - return { node: null, pair: null }; - } - lastPair = pair; - node = pair.value; - continue; - } - - if (YAML.isSeq(node) && typeof segment === 'number') { - const nextNode = node.items[segment] as YAML.Node | undefined; - if (!nextNode) { - return { node: null, pair: null }; - } - node = nextNode; - continue; - } - - return { node: null, pair: null }; - } - - return { node, pair: lastPair }; -}; - -const toMonacoPosition = (linePos: { line: number; col: number }) => { - return { - lineNumber: linePos.line > 0 ? linePos.line : 1, - column: linePos.col > 0 ? linePos.col : 1, - }; -}; - -const getRangeFromOffsets = (lineCounter: LineCounter, start: number, end: number) => { - const startPos = toMonacoPosition(lineCounter.linePos(start)); - const endPos = toMonacoPosition(lineCounter.linePos(end)); - return new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column); -}; - -const buildYamlValidationMarkers = (model: monaco.editor.ITextModel) => { - const text = model.getValue(); - const lineCounter = new LineCounter(); - const doc = YAML.parseDocument(text, { lineCounter }); - const markers: monaco.editor.IMarkerData[] = []; - - for (const error of doc.errors) { - const [start, end] = error.pos ?? [0, 0]; - const range = getRangeFromOffsets(lineCounter, start, Math.max(end, start + 1)); - markers.push({ - message: error.message, - severity: monaco.MarkerSeverity.Error, - startLineNumber: range.startLineNumber, - startColumn: range.startColumn, - endLineNumber: range.endLineNumber, - endColumn: range.endColumn, - }); - } - - if (doc.errors.length === 0) { - const parsed = createRuleDataSchema.safeParse(doc.toJS()); - if (!parsed.success) { - for (const issue of parsed.error.issues) { - const path = issue.path as Array; - const { node, pair } = findYamlNodeForPath(doc, path); - const rangeSource = pair?.key ?? node; - const range = rangeSource?.range - ? getRangeFromOffsets(lineCounter, rangeSource.range[0], rangeSource.range[1]) - : getRangeFromOffsets(lineCounter, 0, 1); - markers.push({ - message: issue.message, - severity: monaco.MarkerSeverity.Error, - startLineNumber: range.startLineNumber, - startColumn: range.startColumn, - endLineNumber: range.endLineNumber, - endColumn: range.endColumn, - }); - } - } - } - - monaco.editor.setModelMarkers(model, 'alertingV2YamlSchema', markers); -}; - -const getYamlPathAtPosition = (text: string, position: monaco.Position): string[] | null => { - const lines = text.split('\n'); - const lineIndex = Math.max(0, position.lineNumber - 1); - const line = lines[lineIndex] ?? ''; - const indent = line.match(/^\s*/)?.[0].length ?? 0; - const keyMatch = /^\s*([A-Za-z0-9_]+)\s*:/.exec(line); - - if (keyMatch) { - const key = keyMatch[1]; - if (indent === 0) { - return [key]; - } - for (let i = lineIndex - 1; i >= 0; i--) { - const parentLine = lines[i]; - const parentMatch = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(parentLine); - if (!parentMatch) { - continue; - } - const parentIndent = parentMatch[1].length; - if (parentIndent < indent) { - return [parentMatch[2], key]; - } - } - return [key]; - } - - const cursorOffset = lines.slice(0, lineIndex).reduce((acc, curr) => acc + curr.length + 1, 0); - const queryContext = findYamlQueryContext(text, cursorOffset + position.column - 1); - if (queryContext) { - return ['query']; - } - - for (let i = lineIndex - 1; i >= 0; i--) { - const parentLine = lines[i]; - const parentMatch = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(parentLine); - if (!parentMatch) { - continue; - } - const parentIndent = parentMatch[1].length; - if (parentIndent < indent) { - return [parentMatch[2]]; - } - } - - return null; -}; - -const ALERTING_V2_YAML_ESQL_LANG_ID = 'alertingV2YamlEsql'; - -class AlertingYamlState implements monaco.languages.IState { - constructor( - public readonly kind: 'none' | 'pending' | 'block' | 'inline', - public readonly baseIndent: number, - public readonly blockIndent: number - ) {} - - clone() { - return new AlertingYamlState(this.kind, this.baseIndent, this.blockIndent); - } - - equals(other: monaco.languages.IState) { - if (!(other instanceof AlertingYamlState)) { - return false; - } - return ( - other.kind === this.kind && - other.baseIndent === this.baseIndent && - other.blockIndent === this.blockIndent - ); - } -} - -const ensureAlertingYamlLanguage = () => { - const languages = monaco.languages.getLanguages(); - if (languages.some(({ id }) => id === ALERTING_V2_YAML_ESQL_LANG_ID)) { - return; - } - - void ESQLLang.onLanguage?.(); - - monaco.languages.register({ id: ALERTING_V2_YAML_ESQL_LANG_ID }); - if (YamlLang.languageConfiguration) { - monaco.languages.setLanguageConfiguration( - ALERTING_V2_YAML_ESQL_LANG_ID, - YamlLang.languageConfiguration - ); - } - - const normalizeEsqlTokenType = (tokenType: string) => { - const [base] = tokenType.split('.'); - return base || tokenType; - }; - - const toMonacoTokens = ( - tokens: monaco.Token[] | undefined, - transform?: (tokenType: string) => string - ): monaco.languages.IToken[] => { - if (!tokens) { - return []; - } - return tokens.map((token) => ({ - startIndex: token.offset, - scopes: transform ? transform(token.type) : token.type, - })); - }; - - const tokenizeYamlLine = (line: string) => { - const yamlTokens = monaco.editor.tokenize(line, YAML_LANG_ID)[0]; - return toMonacoTokens(yamlTokens); - }; - - const tokenizeEsqlLine = (line: string, offset: number) => { - const esqlTokens = monaco.editor.tokenize(line, ESQL_LANG_ID)[0]; - return toMonacoTokens(esqlTokens, normalizeEsqlTokenType).map((token) => ({ - startIndex: token.startIndex + offset, - scopes: token.scopes, - })); - }; - - monaco.languages.setTokensProvider(ALERTING_V2_YAML_ESQL_LANG_ID, { - getInitialState: () => new AlertingYamlState('none', 0, 0), - tokenize: (line, state) => { - if (!(state instanceof AlertingYamlState)) { - return { tokens: tokenizeYamlLine(line), endState: new AlertingYamlState('none', 0, 0) }; - } - - const indent = line.match(/^\s*/)?.[0].length ?? 0; - const trimmed = line.trim(); - - if (state.kind === 'pending') { - if (trimmed === '') { - return { tokens: tokenizeYamlLine(line), endState: state }; - } - - if (indent > state.baseIndent) { - const esqlText = line.slice(indent); - const tokens = [ - { startIndex: 0, scopes: 'source.yaml' }, - ...tokenizeEsqlLine(esqlText, indent), - ]; - return { - tokens, - endState: new AlertingYamlState('block', state.baseIndent, indent), - }; - } - - return { - tokens: tokenizeYamlLine(line), - endState: new AlertingYamlState('none', 0, 0), - }; - } - - if (state.kind === 'block') { - if (trimmed !== '' && indent < state.blockIndent) { - return { - tokens: tokenizeYamlLine(line), - endState: new AlertingYamlState('none', 0, 0), - }; - } - - const esqlText = indent >= state.blockIndent ? line.slice(state.blockIndent) : ''; - const tokens = [ - { startIndex: 0, scopes: 'source.yaml' }, - ...tokenizeEsqlLine(esqlText, state.blockIndent), - ]; - return { - tokens, - endState: state, - }; - } - - if (state.kind === 'inline') { - if (trimmed === '' && indent <= state.baseIndent) { - return { - tokens: tokenizeYamlLine(line), - endState: new AlertingYamlState('none', 0, 0), - }; - } - - if (trimmed !== '' && indent <= state.baseIndent) { - return { - tokens: tokenizeYamlLine(line), - endState: new AlertingYamlState('none', 0, 0), - }; - } - - const continuationIndent = - state.blockIndent > 0 ? state.blockIndent : indent > state.baseIndent ? indent : 0; - - if (continuationIndent === 0) { - return { - tokens: tokenizeYamlLine(line), - endState: new AlertingYamlState('none', 0, 0), - }; - } - - const esqlText = indent >= continuationIndent ? line.slice(continuationIndent) : ''; - const tokens = [ - { startIndex: 0, scopes: 'source.yaml' }, - ...tokenizeEsqlLine(esqlText, continuationIndent), - ]; - return { - tokens, - endState: new AlertingYamlState('inline', state.baseIndent, continuationIndent), - }; - } - - const queryMatch = /^(\s*)(query)(:)\s*(.*)$/.exec(line); - if (!queryMatch) { - return { tokens: tokenizeYamlLine(line), endState: state }; - } - - const baseIndent = queryMatch[1].length; - const keyStart = baseIndent; - const keyEnd = keyStart + queryMatch[2].length; - const colonPos = keyEnd; - const value = queryMatch[4] ?? ''; - const valueStartIndex = colonPos + 1 + (line.slice(colonPos + 1).length - value.length); - const trimmedValue = value.trim(); - - // Build tokens: key gets 'type.yaml' (same as other YAML keys), colon gets punctuation - const baseTokens: monaco.languages.IToken[] = [ - { startIndex: 0, scopes: 'source.yaml' }, - { startIndex: keyStart, scopes: 'type.yaml' }, // "query" highlighted as YAML key - { startIndex: colonPos, scopes: 'operators.yaml' }, // ":" highlighted as operator - ]; - - if (trimmedValue.startsWith('>') || trimmedValue.startsWith('|')) { - // Block scalar indicator - tokenize as YAML - const yamlTokens = tokenizeYamlLine(line); - return { - tokens: yamlTokens, - endState: new AlertingYamlState('pending', baseIndent, 0), - }; - } - - if (trimmedValue.length > 0) { - const rawValue = value.trimStart(); - const rawValueOffset = valueStartIndex + (value.length - rawValue.length); - const quote = rawValue.startsWith('"') || rawValue.startsWith("'") ? rawValue[0] : null; - const closingIndex = quote ? rawValue.lastIndexOf(quote) : rawValue.length; - const queryText = rawValue.slice( - quote ? 1 : 0, - closingIndex > 0 ? closingIndex : undefined - ); - const queryOffset = rawValueOffset + (quote ? 1 : 0); - const tokens = [...baseTokens, ...tokenizeEsqlLine(queryText, queryOffset)]; - return { tokens, endState: new AlertingYamlState('inline', baseIndent, 0) }; - } - - return { tokens: baseTokens, endState: new AlertingYamlState('pending', baseIndent, 0) }; - }, - }); -}; - -ensureAlertingYamlLanguage(); - -/** - * Find the ES|QL query context at the cursor position. - * Handles inline queries, block scalar queries (| or >), and multi-line continuation. - */ -const findYamlQueryContext = (text: string, cursorOffset: number): QueryContext | null => { - const lines = text.split('\n'); - - // Build line start offsets and find cursor line - const lineStartOffsets: number[] = []; - let runningOffset = 0; - let cursorLine = 0; - - for (let i = 0; i < lines.length; i++) { - lineStartOffsets.push(runningOffset); - if (cursorOffset >= runningOffset && cursorOffset <= runningOffset + lines[i].length) { - cursorLine = i; - } - runningOffset += lines[i].length + 1; // +1 for newline - } - - // Search backwards for the `query:` key - for (let queryLineIdx = cursorLine; queryLineIdx >= 0; queryLineIdx--) { - const line = lines[queryLineIdx]; - const queryMatch = /^(\s*)query:\s*(.*)$/.exec(line); - if (!queryMatch) continue; - - const baseIndent = queryMatch[1].length; - const afterColon = queryMatch[2] ?? ''; - const trimmedAfterColon = afterColon.trim(); - - // Case 1: Block scalar (| or >) - if (trimmedAfterColon.startsWith('|') || trimmedAfterColon.startsWith('>')) { - // Find the block indent from first content line - let blockIndent = 0; - for (let j = queryLineIdx + 1; j < lines.length; j++) { - if (lines[j].trim() === '') continue; - const lineIndent = lines[j].match(/^\s*/)?.[0].length ?? 0; - if (lineIndent > baseIndent) { - blockIndent = lineIndent; - } - break; - } - - // If no content yet, use cursor indent or default - if (blockIndent === 0) { - if (cursorLine <= queryLineIdx) return null; - const cursorIndent = lines[cursorLine].match(/^\s*/)?.[0].length ?? 0; - blockIndent = Math.max(baseIndent + 2, cursorIndent); - } - - // Find end of block - let endLine = queryLineIdx + 1; - while (endLine < lines.length) { - const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; - if (lines[endLine].trim() !== '' && lineIndent < blockIndent) break; - endLine++; - } - - // Check if cursor is in the block - if (cursorLine <= queryLineIdx || cursorLine >= endLine) return null; - - // Extract query lines - const queryLines = lines - .slice(queryLineIdx + 1, endLine) - .map((lineText) => (lineText.length >= blockIndent ? lineText.slice(blockIndent) : '')); - const queryText = queryLines.join('\n'); - - // Calculate offset within query - const cursorLineInQuery = cursorLine - (queryLineIdx + 1); - const cursorColInLine = Math.max( - 0, - cursorOffset - lineStartOffsets[cursorLine] - blockIndent - ); - const offsetBeforeCursorLine = queryLines - .slice(0, cursorLineInQuery) - .reduce((acc, l) => acc + l.length + 1, 0); - - return { - queryText, - queryOffset: Math.max( - 0, - Math.min(queryText.length, offsetBeforeCursorLine + cursorColInLine) - ), - }; - } - - // Case 2a: Empty value but cursor is on the query line (e.g., "query: " with cursor after colon) - if (trimmedAfterColon.length === 0 && cursorLine === queryLineIdx) { - // Find where the cursor would be relative to where a value would start - const colonIdx = line.indexOf(':'); - const valueStartCol = colonIdx + 1; - const valueStartOffset = lineStartOffsets[queryLineIdx] + valueStartCol; - - // Cursor must be at or after the colon - if (cursorOffset >= valueStartOffset) { - return { - queryText: '', - queryOffset: 0, - }; - } - return null; - } - - // Case 2b: Inline value (possibly with continuation lines) - if (trimmedAfterColon.length > 0) { - // afterColon is already trimmed of leading whitespace by the regex's \s* - // So we calculate position by finding where afterColon starts in the line - const valueStartCol = line.length - afterColon.length; - const valueStartOffset = lineStartOffsets[queryLineIdx] + valueStartCol; - - // Handle quoted strings - const quote = afterColon.startsWith('"') || afterColon.startsWith("'") ? afterColon[0] : null; - const closingQuoteIdx = quote ? afterColon.lastIndexOf(quote) : -1; - const queryStartOffset = valueStartOffset + (quote ? 1 : 0); - const queryEndOffset = - closingQuoteIdx > 0 - ? valueStartOffset + closingQuoteIdx - : valueStartOffset + afterColon.length; - - // Check for multi-line continuation - let continuationIndent = 0; - for (let j = queryLineIdx + 1; j < lines.length; j++) { - if (lines[j].trim() === '') continue; - const lineIndent = lines[j].match(/^\s*/)?.[0].length ?? 0; - if (lineIndent > baseIndent) { - continuationIndent = lineIndent; - } - break; - } - - // Single line query (no continuation) - if (continuationIndent === 0) { - if (cursorLine !== queryLineIdx) return null; - if (cursorOffset < queryStartOffset || cursorOffset > queryEndOffset) return null; - - const queryText = afterColon.slice( - quote ? 1 : 0, - closingQuoteIdx > 0 ? closingQuoteIdx : undefined - ); - return { - queryText, - queryOffset: Math.max(0, Math.min(queryText.length, cursorOffset - queryStartOffset)), - }; - } - - // Multi-line with continuation - let endLine = queryLineIdx + 1; - while (endLine < lines.length) { - const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; - if (lines[endLine].trim() !== '' && lineIndent <= baseIndent) break; - endLine++; - } - - if (cursorLine < queryLineIdx || cursorLine >= endLine) return null; - - // Build query text - const firstLineText = afterColon.slice( - quote ? 1 : 0, - closingQuoteIdx > 0 ? closingQuoteIdx : undefined - ); - const continuationLines = lines.slice(queryLineIdx + 1, endLine).map((lineText) => { - const lineIndent = lineText.match(/^\s*/)?.[0].length ?? 0; - return lineIndent >= continuationIndent ? lineText.slice(continuationIndent) : ''; - }); - const queryText = [firstLineText, ...continuationLines].join('\n'); - - // Calculate offset - if (cursorLine === queryLineIdx) { - if (cursorOffset < queryStartOffset) return null; - return { - queryText, - queryOffset: Math.max(0, Math.min(firstLineText.length, cursorOffset - queryStartOffset)), - }; - } - - const cursorLineInQuery = cursorLine - (queryLineIdx + 1); - const cursorColInLine = Math.max( - 0, - cursorOffset - lineStartOffsets[cursorLine] - continuationIndent - ); - const offsetBeforeCursorLine = - firstLineText.length + - 1 + - continuationLines.slice(0, cursorLineInQuery).reduce((acc, l) => acc + l.length + 1, 0); - - return { - queryText, - queryOffset: Math.max( - 0, - Math.min(queryText.length, offsetBeforeCursorLine + cursorColInLine) - ), - }; - } - - // Case 3: Empty value - check if cursor is on a continuation line - if (cursorLine <= queryLineIdx) return null; - - // Find block indent from subsequent lines - let blockIndent = 0; - for (let j = queryLineIdx + 1; j < lines.length; j++) { - if (lines[j].trim() === '') continue; - const lineIndent = lines[j].match(/^\s*/)?.[0].length ?? 0; - if (lineIndent > baseIndent) { - blockIndent = lineIndent; - } - break; - } - - if (blockIndent === 0) { - const cursorIndent = lines[cursorLine].match(/^\s*/)?.[0].length ?? 0; - blockIndent = Math.max(baseIndent + 2, cursorIndent); - } - - // Find end of block - let endLine = queryLineIdx + 1; - while (endLine < lines.length) { - const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; - if (lines[endLine].trim() !== '' && lineIndent < blockIndent) break; - endLine++; - } - - if (cursorLine >= endLine) return null; - - // Extract query - const queryLines = lines - .slice(queryLineIdx + 1, endLine) - .map((lineText) => (lineText.length >= blockIndent ? lineText.slice(blockIndent) : '')); - const queryText = queryLines.join('\n'); - - const cursorLineInQuery = cursorLine - (queryLineIdx + 1); - const cursorColInLine = Math.max(0, cursorOffset - lineStartOffsets[cursorLine] - blockIndent); - const offsetBeforeCursorLine = queryLines - .slice(0, cursorLineInQuery) - .reduce((acc, l) => acc + l.length + 1, 0); - - return { - queryText, - queryOffset: Math.max( - 0, - Math.min(queryText.length, offsetBeforeCursorLine + cursorColInLine) - ), - }; - } - - return null; -}; - -const toCompletionItems = ( - suggestions: Array<{ - label: string; - text: string; - asSnippet?: boolean; - kind?: string; - detail?: string; - documentation?: string | { value: string }; - sortText?: string; - filterText?: string; - }>, - range: monaco.Range -): monaco.languages.CompletionItem[] => { - return suggestions.map((item) => { - const kind = - item.kind && item.kind in monaco.languages.CompletionItemKind - ? monaco.languages.CompletionItemKind[ - item.kind as keyof typeof monaco.languages.CompletionItemKind - ] - : monaco.languages.CompletionItemKind.Method; - return { - label: item.label, - insertText: item.text, - filterText: item.filterText, - kind, - detail: item.detail, - documentation: item.documentation, - sortText: item.sortText, - insertTextRules: item.asSnippet - ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet - : undefined, - range, - }; - }); -}; - export const CreateRulePage = () => { const { id: ruleId } = useParams<{ id?: string }>(); const isEditing = Boolean(ruleId); @@ -969,8 +78,6 @@ export const CreateRulePage = () => { const http = useService(CoreStart('http')); const application = useService(CoreStart('application')); const data = useService(PluginStart('data')) as DataPublicPluginStart; - const editorSuggestDisposable = useRef(null); - const editorValidationDisposable = useRef(null); const [yaml, setYaml] = useState(DEFAULT_RULE_YAML); const [error, setError] = useState(null); const [errorTitle, setErrorTitle] = useState(null); @@ -988,105 +95,6 @@ export const CreateRulePage = () => { [application, http, data.search.search] ); - const suggestionProvider = useMemo( - () => ({ - triggerCharacters: [...ESQL_AUTOCOMPLETE_TRIGGER_CHARS, ':', ' '], - provideCompletionItems: async (model, position) => { - const fullText = model.getValue(); - const cursorOffset = model.getOffsetAt(position); - const queryContext = findYamlQueryContext(fullText, cursorOffset); - - const word = model.getWordUntilPosition(position); - const range = new monaco.Range( - position.lineNumber, - word.startColumn, - position.lineNumber, - word.endColumn - ); - - if (queryContext) { - const suggestions = await suggest( - queryContext.queryText, - queryContext.queryOffset, - esqlCallbacks - ); - return { - suggestions: toCompletionItems(suggestions, range), - }; - } - - const completionContext = getCompletionContext(fullText, position); - if (!completionContext) { - return { suggestions: [] }; - } - - if (completionContext.isValuePosition && completionContext.currentKey) { - const typeInfo = getSchemaTypeInfo([ - ...completionContext.parentPath, - completionContext.currentKey, - ]); - if (typeInfo?.isBoolean) { - return { - suggestions: ['true', 'false'].map((value) => ({ - label: value, - insertText: value, - kind: monaco.languages.CompletionItemKind.Value, - range, - })), - }; - } - if (typeInfo?.enumValues) { - return { - suggestions: typeInfo.enumValues.map((value) => ({ - label: value, - insertText: value, - kind: monaco.languages.CompletionItemKind.Value, - range, - })), - }; - } - return { suggestions: [] }; - } - - // Get schema properties and filter out keys that are already present - const properties = getSchemaProperties(completionContext.parentPath); - const existingKeys = getExistingYamlKeys(fullText, completionContext.parentPath); - - return { - suggestions: properties - .filter(({ key }) => !existingKeys.has(key)) - .map(({ key, description }) => ({ - label: key, - insertText: `${key}: `, - kind: monaco.languages.CompletionItemKind.Property, - documentation: description ? { value: description } : undefined, - range, - })), - }; - }, - }), - [esqlCallbacks] - ); - - const hoverProvider = useMemo( - () => ({ - provideHover: (model, position) => { - const path = getYamlPathAtPosition(model.getValue(), position); - if (!path) { - return null; - } - const description = getSchemaDescription(path); - if (!description) { - return null; - } - return { - contents: [{ value: description }], - }; - }, - }), - [] - ); - useEffect(() => { if (!ruleId) { return; @@ -1253,55 +261,12 @@ export const CreateRulePage = () => { /> } > - setYaml(value)} - languageId={ALERTING_V2_YAML_ESQL_LANG_ID} - editorDidMount={(editor) => { - const model = editor.getModel(); - if (!model) { - return; - } - // Force tokenization on initial render for the custom YAML+ESQL language. - monaco.editor.setModelLanguage(model, YAML_LANG_ID); - monaco.editor.setModelLanguage(model, ALERTING_V2_YAML_ESQL_LANG_ID); - - editorSuggestDisposable.current?.dispose(); - editorSuggestDisposable.current = editor.onDidChangeModelContent(() => { - const position = editor.getPosition(); - const currentModel = editor.getModel(); - if (!position || !currentModel) { - return; - } - const offset = currentModel.getOffsetAt(position); - if (findYamlQueryContext(currentModel.getValue(), offset)) { - editor.trigger('alerting_v2', 'editor.action.triggerSuggest', null); - } - }); - - buildYamlValidationMarkers(model); - editorValidationDisposable.current?.dispose(); - editorValidationDisposable.current = editor.onDidChangeModelContent(() => { - buildYamlValidationMarkers(model); - }); - }} - editorWillUnmount={() => { - editorSuggestDisposable.current?.dispose(); - editorSuggestDisposable.current = null; - editorValidationDisposable.current?.dispose(); - editorValidationDisposable.current = null; - }} - height={320} - fullWidth + onChange={setYaml} + esqlCallbacks={esqlCallbacks} + isReadOnly={isLoadingRule || isSubmitting} dataTestSubj="alertingV2CreateRuleYaml" - suggestionProvider={suggestionProvider} - hoverProvider={hoverProvider} - options={{ - minimap: { enabled: false }, - wordWrap: 'on', - lineNumbers: 'on', - readOnly: isLoadingRule || isSubmitting, - }} /> diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor.tsx new file mode 100644 index 0000000000000..21b904f1bbf60 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor.tsx @@ -0,0 +1,1062 @@ +/* + * 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, useRef } from 'react'; +import { CodeEditorField } from '@kbn/code-editor'; +import { + ESQL_AUTOCOMPLETE_TRIGGER_CHARS, + ESQL_LANG_ID, + ESQLLang, + monaco, + YamlLang, + YAML_LANG_ID, +} from '@kbn/monaco'; +import type { ESQLCallbacks } from '@kbn/esql-types'; +import { suggest } from '@kbn/esql-language'; +import YAML, { LineCounter } from 'yaml'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { createRuleDataSchema } from '../../common/schemas/create_rule_data_schema'; + +// ============================================================================ +// Types +// ============================================================================ + +interface QueryContext { + queryText: string; + queryOffset: number; +} + +interface SchemaPropertyInfo { + key: string; + description?: string; + type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'unknown'; + isEnum?: boolean; + enumValues?: string[]; +} + +interface JsonSchema { + type?: string | string[]; + properties?: Record; + items?: JsonSchema; + description?: string; + enum?: Array; + anyOf?: JsonSchema[]; + oneOf?: JsonSchema[]; + allOf?: JsonSchema[]; + $ref?: string; + definitions?: Record; + $defs?: Record; + default?: unknown; +} + +interface YamlRuleEditorProps { + value: string; + onChange: (value: string) => void; + esqlCallbacks: ESQLCallbacks; + isReadOnly?: boolean; + height?: number; + dataTestSubj?: string; +} + +// ============================================================================ +// JSON Schema Utilities +// ============================================================================ + +let cachedJsonSchema: JsonSchema | null = null; +const getJsonSchema = (): JsonSchema => { + if (!cachedJsonSchema) { + cachedJsonSchema = zodToJsonSchema(createRuleDataSchema, { + $refStrategy: 'none', + errorMessages: true, + }) as JsonSchema; + } + return cachedJsonSchema; +}; + +const resolveSchema = (schema: JsonSchema): JsonSchema => { + if (schema.anyOf) { + const nonNull = schema.anyOf.find( + (s) => s.type !== 'null' && !(Array.isArray(s.type) && s.type.includes('null')) + ); + if (nonNull) return resolveSchema(nonNull); + return schema.anyOf[0] ? resolveSchema(schema.anyOf[0]) : schema; + } + if (schema.oneOf) { + const nonNull = schema.oneOf.find( + (s) => s.type !== 'null' && !(Array.isArray(s.type) && s.type.includes('null')) + ); + if (nonNull) return resolveSchema(nonNull); + return schema.oneOf[0] ? resolveSchema(schema.oneOf[0]) : schema; + } + if (schema.allOf && schema.allOf.length === 1) { + return resolveSchema(schema.allOf[0]); + } + return schema; +}; + +const getSchemaNode = (path: Array): JsonSchema | undefined => { + let current: JsonSchema = getJsonSchema(); + + for (const segment of path) { + current = resolveSchema(current); + + if (typeof segment === 'number') { + if (current.items) { + current = current.items; + continue; + } + return undefined; + } + + if (current.properties && segment in current.properties) { + current = current.properties[segment]; + continue; + } + + return undefined; + } + + return current; +}; + +const getSchemaType = (schema: JsonSchema): SchemaPropertyInfo['type'] => { + const resolved = resolveSchema(schema); + const type = Array.isArray(resolved.type) ? resolved.type[0] : resolved.type; + + switch (type) { + case 'string': + return 'string'; + case 'number': + case 'integer': + return 'number'; + case 'boolean': + return 'boolean'; + case 'array': + return 'array'; + case 'object': + return 'object'; + default: + return 'unknown'; + } +}; + +const getSchemaProperties = (path: string[]): SchemaPropertyInfo[] => { + const node = getSchemaNode(path); + if (!node) return []; + + const resolved = resolveSchema(node); + if (!resolved.properties) return []; + + return Object.entries(resolved.properties).map(([key, propSchema]) => { + const resolvedProp = resolveSchema(propSchema); + const type = getSchemaType(propSchema); + const isEnum = Boolean(resolvedProp.enum); + const enumValues = isEnum ? (resolvedProp.enum as string[]) : undefined; + + return { + key, + description: propSchema.description ?? resolvedProp.description, + type, + isEnum, + enumValues, + }; + }); +}; + +const getSchemaDescription = (path: string[]): string | undefined => { + const node = getSchemaNode(path); + if (!node) return undefined; + const resolved = resolveSchema(node); + return node.description ?? resolved.description; +}; + +const getSchemaTypeInfo = ( + path: string[] +): { type: string; isBoolean: boolean; enumValues?: string[] } | undefined => { + const node = getSchemaNode(path); + if (!node) return undefined; + + const resolved = resolveSchema(node); + const type = Array.isArray(resolved.type) ? resolved.type[0] : resolved.type; + + if (type === 'boolean') { + return { type: 'boolean', isBoolean: true }; + } + if (resolved.enum) { + return { type: 'enum', isBoolean: false, enumValues: resolved.enum as string[] }; + } + + return { type: type ?? 'unknown', isBoolean: false }; +}; + +// ============================================================================ +// YAML Utilities +// ============================================================================ + +const getExistingYamlKeys = (text: string, parentPath: string[]): Set => { + const keys = new Set(); + const lines = text.split('\n'); + + if (parentPath.length === 0) { + for (const line of lines) { + const match = /^([A-Za-z0-9_]+)\s*:/.exec(line); + if (match) { + keys.add(match[1]); + } + } + } else { + let parentIndent = -1; + let inParent = false; + + for (const line of lines) { + const keyMatch = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(line); + + if (keyMatch) { + const key = keyMatch[2]; + const keyIndent = keyMatch[1].length; + + if (!inParent && key === parentPath[parentPath.length - 1] && keyIndent === 0) { + inParent = true; + parentIndent = keyIndent; + continue; + } + + if (inParent) { + if (keyIndent <= parentIndent && line.trim() !== '') { + break; + } + if (keyIndent === parentIndent + 2) { + keys.add(key); + } + } + } + } + } + + return keys; +}; + +const getCompletionContext = (text: string, position: monaco.Position) => { + const lines = text.split('\n'); + const lineIndex = Math.max(0, position.lineNumber - 1); + const line = lines[lineIndex] ?? ''; + const indent = line.match(/^\s*/)?.[0].length ?? 0; + const keyMatch = /^(\s*)([A-Za-z0-9_]+)\s*:(.*)$/.exec(line); + + if (keyMatch) { + const keyIndent = keyMatch[1].length; + const key = keyMatch[2]; + const hasValue = keyMatch[3].trim().length > 0; + const isValuePosition = hasValue || position.column > keyMatch[0].indexOf(':') + 1; + const parentPath = keyIndent === 0 ? [] : getYamlPathAtPosition(text, position)?.slice(0, -1); + return { + parentPath: parentPath ?? [], + currentKey: key, + isValuePosition, + }; + } + + if (indent === 0) { + return { parentPath: [], currentKey: null, isValuePosition: false }; + } + + for (let i = lineIndex - 1; i >= 0; i--) { + const match = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(lines[i]); + if (!match) { + continue; + } + const parentIndent = match[1].length; + if (parentIndent < indent) { + return { parentPath: [match[2]], currentKey: null, isValuePosition: false }; + } + } + + return { parentPath: [], currentKey: null, isValuePosition: false }; +}; + +const findYamlNodeForPath = (doc: YAML.Document, path: Array) => { + let node: YAML.Node | null | undefined = doc.contents; + let lastPair: YAML.Pair | null = null; + + for (const segment of path) { + if (YAML.isMap(node)) { + const pair = node.items.find( + (item) => YAML.isScalar(item.key) && item.key.value === segment + ) as YAML.Pair | undefined; + if (!pair) { + return { node: null, pair: null }; + } + lastPair = pair; + node = pair.value; + continue; + } + + if (YAML.isSeq(node) && typeof segment === 'number') { + const nextNode = node.items[segment] as YAML.Node | undefined; + if (!nextNode) { + return { node: null, pair: null }; + } + node = nextNode; + continue; + } + + return { node: null, pair: null }; + } + + return { node, pair: lastPair }; +}; + +const toMonacoPosition = (linePos: { line: number; col: number }) => { + return { + lineNumber: linePos.line > 0 ? linePos.line : 1, + column: linePos.col > 0 ? linePos.col : 1, + }; +}; + +const getRangeFromOffsets = (lineCounter: LineCounter, start: number, end: number) => { + const startPos = toMonacoPosition(lineCounter.linePos(start)); + const endPos = toMonacoPosition(lineCounter.linePos(end)); + return new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column); +}; + +const buildYamlValidationMarkers = (model: monaco.editor.ITextModel) => { + const text = model.getValue(); + const lineCounter = new LineCounter(); + const doc = YAML.parseDocument(text, { lineCounter }); + const markers: monaco.editor.IMarkerData[] = []; + + for (const error of doc.errors) { + const [start, end] = error.pos ?? [0, 0]; + const range = getRangeFromOffsets(lineCounter, start, Math.max(end, start + 1)); + markers.push({ + message: error.message, + severity: monaco.MarkerSeverity.Error, + startLineNumber: range.startLineNumber, + startColumn: range.startColumn, + endLineNumber: range.endLineNumber, + endColumn: range.endColumn, + }); + } + + if (doc.errors.length === 0) { + const parsed = createRuleDataSchema.safeParse(doc.toJS()); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + const path = issue.path as Array; + const { node, pair } = findYamlNodeForPath(doc, path); + const rangeSource = pair?.key ?? node; + const range = rangeSource?.range + ? getRangeFromOffsets(lineCounter, rangeSource.range[0], rangeSource.range[1]) + : getRangeFromOffsets(lineCounter, 0, 1); + markers.push({ + message: issue.message, + severity: monaco.MarkerSeverity.Error, + startLineNumber: range.startLineNumber, + startColumn: range.startColumn, + endLineNumber: range.endLineNumber, + endColumn: range.endColumn, + }); + } + } + } + + monaco.editor.setModelMarkers(model, 'alertingV2YamlSchema', markers); +}; + +const getYamlPathAtPosition = (text: string, position: monaco.Position): string[] | null => { + const lines = text.split('\n'); + const lineIndex = Math.max(0, position.lineNumber - 1); + const line = lines[lineIndex] ?? ''; + const indent = line.match(/^\s*/)?.[0].length ?? 0; + const keyMatch = /^\s*([A-Za-z0-9_]+)\s*:/.exec(line); + + if (keyMatch) { + const key = keyMatch[1]; + if (indent === 0) { + return [key]; + } + for (let i = lineIndex - 1; i >= 0; i--) { + const parentLine = lines[i]; + const parentMatch = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(parentLine); + if (!parentMatch) { + continue; + } + const parentIndent = parentMatch[1].length; + if (parentIndent < indent) { + return [parentMatch[2], key]; + } + } + return [key]; + } + + const cursorOffset = lines.slice(0, lineIndex).reduce((acc, curr) => acc + curr.length + 1, 0); + const queryContext = findYamlQueryContext(text, cursorOffset + position.column - 1); + if (queryContext) { + return ['query']; + } + + for (let i = lineIndex - 1; i >= 0; i--) { + const parentLine = lines[i]; + const parentMatch = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(parentLine); + if (!parentMatch) { + continue; + } + const parentIndent = parentMatch[1].length; + if (parentIndent < indent) { + return [parentMatch[2]]; + } + } + + return null; +}; + +// ============================================================================ +// ES|QL Query Context Detection +// ============================================================================ + +const findYamlQueryContext = (text: string, cursorOffset: number): QueryContext | null => { + const lines = text.split('\n'); + const lineStartOffsets: number[] = []; + let runningOffset = 0; + let cursorLine = 0; + + for (let i = 0; i < lines.length; i++) { + lineStartOffsets.push(runningOffset); + if (cursorOffset >= runningOffset && cursorOffset <= runningOffset + lines[i].length) { + cursorLine = i; + } + runningOffset += lines[i].length + 1; + } + + for (let queryLineIdx = cursorLine; queryLineIdx >= 0; queryLineIdx--) { + const line = lines[queryLineIdx]; + const queryMatch = /^(\s*)query:\s*(.*)$/.exec(line); + if (!queryMatch) continue; + + const baseIndent = queryMatch[1].length; + const afterColon = queryMatch[2] ?? ''; + const trimmedAfterColon = afterColon.trim(); + + // Case 1: Block scalar (| or >) + if (trimmedAfterColon.startsWith('|') || trimmedAfterColon.startsWith('>')) { + let blockIndent = 0; + for (let j = queryLineIdx + 1; j < lines.length; j++) { + if (lines[j].trim() === '') continue; + const lineIndent = lines[j].match(/^\s*/)?.[0].length ?? 0; + if (lineIndent > baseIndent) { + blockIndent = lineIndent; + } + break; + } + + if (blockIndent === 0) { + if (cursorLine <= queryLineIdx) return null; + const cursorIndent = lines[cursorLine].match(/^\s*/)?.[0].length ?? 0; + blockIndent = Math.max(baseIndent + 2, cursorIndent); + } + + let endLine = queryLineIdx + 1; + while (endLine < lines.length) { + const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; + if (lines[endLine].trim() !== '' && lineIndent < blockIndent) break; + endLine++; + } + + if (cursorLine <= queryLineIdx || cursorLine >= endLine) return null; + + const queryLines = lines + .slice(queryLineIdx + 1, endLine) + .map((lineText) => (lineText.length >= blockIndent ? lineText.slice(blockIndent) : '')); + const queryText = queryLines.join('\n'); + + const cursorLineInQuery = cursorLine - (queryLineIdx + 1); + const cursorColInLine = Math.max( + 0, + cursorOffset - lineStartOffsets[cursorLine] - blockIndent + ); + const offsetBeforeCursorLine = queryLines + .slice(0, cursorLineInQuery) + .reduce((acc, l) => acc + l.length + 1, 0); + + return { + queryText, + queryOffset: Math.max( + 0, + Math.min(queryText.length, offsetBeforeCursorLine + cursorColInLine) + ), + }; + } + + // Case 2a: Empty value but cursor is on the query line + if (trimmedAfterColon.length === 0 && cursorLine === queryLineIdx) { + const colonIdx = line.indexOf(':'); + const valueStartCol = colonIdx + 1; + const valueStartOffset = lineStartOffsets[queryLineIdx] + valueStartCol; + + if (cursorOffset >= valueStartOffset) { + return { queryText: '', queryOffset: 0 }; + } + return null; + } + + // Case 2b: Inline value (possibly with continuation lines) + if (trimmedAfterColon.length > 0) { + const valueStartCol = line.length - afterColon.length; + const valueStartOffset = lineStartOffsets[queryLineIdx] + valueStartCol; + + const quote = afterColon.startsWith('"') || afterColon.startsWith("'") ? afterColon[0] : null; + const closingQuoteIdx = quote ? afterColon.lastIndexOf(quote) : -1; + const queryStartOffset = valueStartOffset + (quote ? 1 : 0); + const queryEndOffset = + closingQuoteIdx > 0 + ? valueStartOffset + closingQuoteIdx + : valueStartOffset + afterColon.length; + + let continuationIndent = 0; + for (let j = queryLineIdx + 1; j < lines.length; j++) { + if (lines[j].trim() === '') continue; + const lineIndent = lines[j].match(/^\s*/)?.[0].length ?? 0; + if (lineIndent > baseIndent) { + continuationIndent = lineIndent; + } + break; + } + + if (continuationIndent === 0) { + if (cursorLine !== queryLineIdx) return null; + if (cursorOffset < queryStartOffset || cursorOffset > queryEndOffset) return null; + + const queryText = afterColon.slice( + quote ? 1 : 0, + closingQuoteIdx > 0 ? closingQuoteIdx : undefined + ); + return { + queryText, + queryOffset: Math.max(0, Math.min(queryText.length, cursorOffset - queryStartOffset)), + }; + } + + let endLine = queryLineIdx + 1; + while (endLine < lines.length) { + const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; + if (lines[endLine].trim() !== '' && lineIndent <= baseIndent) break; + endLine++; + } + + if (cursorLine < queryLineIdx || cursorLine >= endLine) return null; + + const firstLineText = afterColon.slice( + quote ? 1 : 0, + closingQuoteIdx > 0 ? closingQuoteIdx : undefined + ); + const continuationLines = lines.slice(queryLineIdx + 1, endLine).map((lineText) => { + const lineIndent = lineText.match(/^\s*/)?.[0].length ?? 0; + return lineIndent >= continuationIndent ? lineText.slice(continuationIndent) : ''; + }); + const queryText = [firstLineText, ...continuationLines].join('\n'); + + if (cursorLine === queryLineIdx) { + if (cursorOffset < queryStartOffset) return null; + return { + queryText, + queryOffset: Math.max(0, Math.min(firstLineText.length, cursorOffset - queryStartOffset)), + }; + } + + const cursorLineInQuery = cursorLine - (queryLineIdx + 1); + const cursorColInLine = Math.max( + 0, + cursorOffset - lineStartOffsets[cursorLine] - continuationIndent + ); + const offsetBeforeCursorLine = + firstLineText.length + + 1 + + continuationLines.slice(0, cursorLineInQuery).reduce((acc, l) => acc + l.length + 1, 0); + + return { + queryText, + queryOffset: Math.max( + 0, + Math.min(queryText.length, offsetBeforeCursorLine + cursorColInLine) + ), + }; + } + + // Case 3: Empty value - check if cursor is on a continuation line + if (cursorLine <= queryLineIdx) return null; + + let blockIndent = 0; + for (let j = queryLineIdx + 1; j < lines.length; j++) { + if (lines[j].trim() === '') continue; + const lineIndent = lines[j].match(/^\s*/)?.[0].length ?? 0; + if (lineIndent > baseIndent) { + blockIndent = lineIndent; + } + break; + } + + if (blockIndent === 0) { + const cursorIndent = lines[cursorLine].match(/^\s*/)?.[0].length ?? 0; + blockIndent = Math.max(baseIndent + 2, cursorIndent); + } + + let endLine = queryLineIdx + 1; + while (endLine < lines.length) { + const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; + if (lines[endLine].trim() !== '' && lineIndent < blockIndent) break; + endLine++; + } + + if (cursorLine >= endLine) return null; + + const queryLines = lines + .slice(queryLineIdx + 1, endLine) + .map((lineText) => (lineText.length >= blockIndent ? lineText.slice(blockIndent) : '')); + const queryText = queryLines.join('\n'); + + const cursorLineInQuery = cursorLine - (queryLineIdx + 1); + const cursorColInLine = Math.max(0, cursorOffset - lineStartOffsets[cursorLine] - blockIndent); + const offsetBeforeCursorLine = queryLines + .slice(0, cursorLineInQuery) + .reduce((acc, l) => acc + l.length + 1, 0); + + return { + queryText, + queryOffset: Math.max( + 0, + Math.min(queryText.length, offsetBeforeCursorLine + cursorColInLine) + ), + }; + } + + return null; +}; + +// ============================================================================ +// Monaco Language Registration +// ============================================================================ + +const ALERTING_V2_YAML_ESQL_LANG_ID = 'alertingV2YamlEsql'; + +class AlertingYamlState implements monaco.languages.IState { + constructor( + public readonly kind: 'none' | 'pending' | 'block' | 'inline', + public readonly baseIndent: number, + public readonly blockIndent: number + ) {} + + clone() { + return new AlertingYamlState(this.kind, this.baseIndent, this.blockIndent); + } + + equals(other: monaco.languages.IState) { + if (!(other instanceof AlertingYamlState)) { + return false; + } + return ( + other.kind === this.kind && + other.baseIndent === this.baseIndent && + other.blockIndent === this.blockIndent + ); + } +} + +const ensureAlertingYamlLanguage = () => { + const languages = monaco.languages.getLanguages(); + if (languages.some(({ id }) => id === ALERTING_V2_YAML_ESQL_LANG_ID)) { + return; + } + + void ESQLLang.onLanguage?.(); + + monaco.languages.register({ id: ALERTING_V2_YAML_ESQL_LANG_ID }); + if (YamlLang.languageConfiguration) { + monaco.languages.setLanguageConfiguration( + ALERTING_V2_YAML_ESQL_LANG_ID, + YamlLang.languageConfiguration + ); + } + + const normalizeEsqlTokenType = (tokenType: string) => { + const [base] = tokenType.split('.'); + return base || tokenType; + }; + + const toMonacoTokens = ( + tokens: monaco.Token[] | undefined, + transform?: (tokenType: string) => string + ): monaco.languages.IToken[] => { + if (!tokens) { + return []; + } + return tokens.map((token) => ({ + startIndex: token.offset, + scopes: transform ? transform(token.type) : token.type, + })); + }; + + const tokenizeYamlLine = (line: string) => { + const yamlTokens = monaco.editor.tokenize(line, YAML_LANG_ID)[0]; + return toMonacoTokens(yamlTokens); + }; + + const tokenizeEsqlLine = (line: string, offset: number) => { + const esqlTokens = monaco.editor.tokenize(line, ESQL_LANG_ID)[0]; + return toMonacoTokens(esqlTokens, normalizeEsqlTokenType).map((token) => ({ + startIndex: token.startIndex + offset, + scopes: token.scopes, + })); + }; + + monaco.languages.setTokensProvider(ALERTING_V2_YAML_ESQL_LANG_ID, { + getInitialState: () => new AlertingYamlState('none', 0, 0), + tokenize: (line, state) => { + if (!(state instanceof AlertingYamlState)) { + return { tokens: tokenizeYamlLine(line), endState: new AlertingYamlState('none', 0, 0) }; + } + + const indent = line.match(/^\s*/)?.[0].length ?? 0; + const trimmed = line.trim(); + + if (state.kind === 'pending') { + if (trimmed === '') { + return { tokens: tokenizeYamlLine(line), endState: state }; + } + + if (indent > state.baseIndent) { + const esqlText = line.slice(indent); + const tokens = [ + { startIndex: 0, scopes: 'source.yaml' }, + ...tokenizeEsqlLine(esqlText, indent), + ]; + return { + tokens, + endState: new AlertingYamlState('block', state.baseIndent, indent), + }; + } + + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('none', 0, 0), + }; + } + + if (state.kind === 'block') { + if (trimmed !== '' && indent < state.blockIndent) { + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('none', 0, 0), + }; + } + + const esqlText = indent >= state.blockIndent ? line.slice(state.blockIndent) : ''; + const tokens = [ + { startIndex: 0, scopes: 'source.yaml' }, + ...tokenizeEsqlLine(esqlText, state.blockIndent), + ]; + return { + tokens, + endState: state, + }; + } + + if (state.kind === 'inline') { + if (trimmed === '' && indent <= state.baseIndent) { + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('none', 0, 0), + }; + } + + if (trimmed !== '' && indent <= state.baseIndent) { + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('none', 0, 0), + }; + } + + const continuationIndent = + state.blockIndent > 0 ? state.blockIndent : indent > state.baseIndent ? indent : 0; + + if (continuationIndent === 0) { + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('none', 0, 0), + }; + } + + const esqlText = indent >= continuationIndent ? line.slice(continuationIndent) : ''; + const tokens = [ + { startIndex: 0, scopes: 'source.yaml' }, + ...tokenizeEsqlLine(esqlText, continuationIndent), + ]; + return { + tokens, + endState: new AlertingYamlState('inline', state.baseIndent, continuationIndent), + }; + } + + const queryMatch = /^(\s*)(query)(:)\s*(.*)$/.exec(line); + if (!queryMatch) { + return { tokens: tokenizeYamlLine(line), endState: state }; + } + + const baseIndent = queryMatch[1].length; + const keyStart = baseIndent; + const keyEnd = keyStart + queryMatch[2].length; + const colonPos = keyEnd; + const value = queryMatch[4] ?? ''; + const valueStartIndex = colonPos + 1 + (line.slice(colonPos + 1).length - value.length); + const trimmedValue = value.trim(); + + const baseTokens: monaco.languages.IToken[] = [ + { startIndex: 0, scopes: 'source.yaml' }, + { startIndex: keyStart, scopes: 'type.yaml' }, + { startIndex: colonPos, scopes: 'operators.yaml' }, + ]; + + if (trimmedValue.startsWith('>') || trimmedValue.startsWith('|')) { + const yamlTokens = tokenizeYamlLine(line); + return { + tokens: yamlTokens, + endState: new AlertingYamlState('pending', baseIndent, 0), + }; + } + + if (trimmedValue.length > 0) { + const rawValue = value.trimStart(); + const rawValueOffset = valueStartIndex + (value.length - rawValue.length); + const quote = rawValue.startsWith('"') || rawValue.startsWith("'") ? rawValue[0] : null; + const closingIndex = quote ? rawValue.lastIndexOf(quote) : rawValue.length; + const queryText = rawValue.slice( + quote ? 1 : 0, + closingIndex > 0 ? closingIndex : undefined + ); + const queryOffset = rawValueOffset + (quote ? 1 : 0); + const tokens = [...baseTokens, ...tokenizeEsqlLine(queryText, queryOffset)]; + return { tokens, endState: new AlertingYamlState('inline', baseIndent, 0) }; + } + + return { tokens: baseTokens, endState: new AlertingYamlState('pending', baseIndent, 0) }; + }, + }); +}; + +// Initialize language on module load +ensureAlertingYamlLanguage(); + +// ============================================================================ +// Completion Items Conversion +// ============================================================================ + +const toCompletionItems = ( + suggestions: Array<{ + label: string; + text: string; + asSnippet?: boolean; + kind?: string; + detail?: string; + documentation?: string | { value: string }; + sortText?: string; + filterText?: string; + }>, + range: monaco.Range +): monaco.languages.CompletionItem[] => { + return suggestions.map((item) => { + const kind = + item.kind && item.kind in monaco.languages.CompletionItemKind + ? monaco.languages.CompletionItemKind[ + item.kind as keyof typeof monaco.languages.CompletionItemKind + ] + : monaco.languages.CompletionItemKind.Method; + return { + label: item.label, + insertText: item.text, + filterText: item.filterText, + kind, + detail: item.detail, + documentation: item.documentation, + sortText: item.sortText, + insertTextRules: item.asSnippet + ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + : undefined, + range, + }; + }); +}; + +// ============================================================================ +// YamlRuleEditor Component +// ============================================================================ + +export const YamlRuleEditor: React.FC = ({ + value, + onChange, + esqlCallbacks, + isReadOnly = false, + height = 320, + dataTestSubj = 'alertingV2YamlRuleEditor', +}) => { + const editorSuggestDisposable = useRef(null); + const editorValidationDisposable = useRef(null); + + const suggestionProvider = useMemo( + () => ({ + triggerCharacters: [...ESQL_AUTOCOMPLETE_TRIGGER_CHARS, ':', ' '], + provideCompletionItems: async (model, position) => { + const fullText = model.getValue(); + const cursorOffset = model.getOffsetAt(position); + const queryContext = findYamlQueryContext(fullText, cursorOffset); + + const word = model.getWordUntilPosition(position); + const range = new monaco.Range( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn + ); + + if (queryContext) { + const suggestions = await suggest( + queryContext.queryText, + queryContext.queryOffset, + esqlCallbacks + ); + return { + suggestions: toCompletionItems(suggestions, range), + }; + } + + const completionContext = getCompletionContext(fullText, position); + if (!completionContext) { + return { suggestions: [] }; + } + + if (completionContext.isValuePosition && completionContext.currentKey) { + const typeInfo = getSchemaTypeInfo([ + ...completionContext.parentPath, + completionContext.currentKey, + ]); + if (typeInfo?.isBoolean) { + return { + suggestions: ['true', 'false'].map((val) => ({ + label: val, + insertText: val, + kind: monaco.languages.CompletionItemKind.Value, + range, + })), + }; + } + if (typeInfo?.enumValues) { + return { + suggestions: typeInfo.enumValues.map((val) => ({ + label: val, + insertText: val, + kind: monaco.languages.CompletionItemKind.Value, + range, + })), + }; + } + return { suggestions: [] }; + } + + const properties = getSchemaProperties(completionContext.parentPath); + const existingKeys = getExistingYamlKeys(fullText, completionContext.parentPath); + + return { + suggestions: properties + .filter(({ key }) => !existingKeys.has(key)) + .map(({ key, description }) => ({ + label: key, + insertText: `${key}: `, + kind: monaco.languages.CompletionItemKind.Property, + documentation: description ? { value: description } : undefined, + range, + })), + }; + }, + }), + [esqlCallbacks] + ); + + const hoverProvider = useMemo( + () => ({ + provideHover: (model, position) => { + const path = getYamlPathAtPosition(model.getValue(), position); + if (!path) { + return null; + } + const description = getSchemaDescription(path); + if (!description) { + return null; + } + return { + contents: [{ value: description }], + }; + }, + }), + [] + ); + + const handleEditorDidMount = useCallback((editor: monaco.editor.IStandaloneCodeEditor) => { + const model = editor.getModel(); + if (!model) { + return; + } + + // Force tokenization on initial render for the custom YAML+ESQL language + monaco.editor.setModelLanguage(model, YAML_LANG_ID); + monaco.editor.setModelLanguage(model, ALERTING_V2_YAML_ESQL_LANG_ID); + + editorSuggestDisposable.current?.dispose(); + editorSuggestDisposable.current = editor.onDidChangeModelContent(() => { + const position = editor.getPosition(); + const currentModel = editor.getModel(); + if (!position || !currentModel) { + return; + } + const offset = currentModel.getOffsetAt(position); + if (findYamlQueryContext(currentModel.getValue(), offset)) { + editor.trigger('alerting_v2', 'editor.action.triggerSuggest', null); + } + }); + + buildYamlValidationMarkers(model); + editorValidationDisposable.current?.dispose(); + editorValidationDisposable.current = editor.onDidChangeModelContent(() => { + buildYamlValidationMarkers(model); + }); + }, []); + + const handleEditorWillUnmount = useCallback(() => { + editorSuggestDisposable.current?.dispose(); + editorSuggestDisposable.current = null; + editorValidationDisposable.current?.dispose(); + editorValidationDisposable.current = null; + }, []); + + return ( + + ); +}; From 47fb98215956e96b0c010e3e781ab14b2bf4a2d0 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Thu, 22 Jan 2026 15:41:12 +0000 Subject: [PATCH 5/8] refactor yaml editor, support configurable query props --- .../public/components/yaml_rule_editor.tsx | 1062 ----------------- .../components/yaml_rule_editor/index.ts | 10 + .../yaml_rule_editor/monaco_language.ts | 316 +++++ .../yaml_rule_editor/query_context.ts | 328 +++++ .../yaml_rule_editor/schema_utils.ts | 162 +++ .../components/yaml_rule_editor/types.ts | 81 ++ .../yaml_rule_editor/yaml_rule_editor.tsx | 254 ++++ .../components/yaml_rule_editor/yaml_utils.ts | 265 ++++ 8 files changed, 1416 insertions(+), 1062 deletions(-) delete mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor.tsx create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/index.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/monaco_language.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/query_context.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/schema_utils.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/types.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_rule_editor.tsx create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_utils.ts diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor.tsx deleted file mode 100644 index 21b904f1bbf60..0000000000000 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor.tsx +++ /dev/null @@ -1,1062 +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, { useCallback, useMemo, useRef } from 'react'; -import { CodeEditorField } from '@kbn/code-editor'; -import { - ESQL_AUTOCOMPLETE_TRIGGER_CHARS, - ESQL_LANG_ID, - ESQLLang, - monaco, - YamlLang, - YAML_LANG_ID, -} from '@kbn/monaco'; -import type { ESQLCallbacks } from '@kbn/esql-types'; -import { suggest } from '@kbn/esql-language'; -import YAML, { LineCounter } from 'yaml'; -import { zodToJsonSchema } from 'zod-to-json-schema'; -import { createRuleDataSchema } from '../../common/schemas/create_rule_data_schema'; - -// ============================================================================ -// Types -// ============================================================================ - -interface QueryContext { - queryText: string; - queryOffset: number; -} - -interface SchemaPropertyInfo { - key: string; - description?: string; - type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'unknown'; - isEnum?: boolean; - enumValues?: string[]; -} - -interface JsonSchema { - type?: string | string[]; - properties?: Record; - items?: JsonSchema; - description?: string; - enum?: Array; - anyOf?: JsonSchema[]; - oneOf?: JsonSchema[]; - allOf?: JsonSchema[]; - $ref?: string; - definitions?: Record; - $defs?: Record; - default?: unknown; -} - -interface YamlRuleEditorProps { - value: string; - onChange: (value: string) => void; - esqlCallbacks: ESQLCallbacks; - isReadOnly?: boolean; - height?: number; - dataTestSubj?: string; -} - -// ============================================================================ -// JSON Schema Utilities -// ============================================================================ - -let cachedJsonSchema: JsonSchema | null = null; -const getJsonSchema = (): JsonSchema => { - if (!cachedJsonSchema) { - cachedJsonSchema = zodToJsonSchema(createRuleDataSchema, { - $refStrategy: 'none', - errorMessages: true, - }) as JsonSchema; - } - return cachedJsonSchema; -}; - -const resolveSchema = (schema: JsonSchema): JsonSchema => { - if (schema.anyOf) { - const nonNull = schema.anyOf.find( - (s) => s.type !== 'null' && !(Array.isArray(s.type) && s.type.includes('null')) - ); - if (nonNull) return resolveSchema(nonNull); - return schema.anyOf[0] ? resolveSchema(schema.anyOf[0]) : schema; - } - if (schema.oneOf) { - const nonNull = schema.oneOf.find( - (s) => s.type !== 'null' && !(Array.isArray(s.type) && s.type.includes('null')) - ); - if (nonNull) return resolveSchema(nonNull); - return schema.oneOf[0] ? resolveSchema(schema.oneOf[0]) : schema; - } - if (schema.allOf && schema.allOf.length === 1) { - return resolveSchema(schema.allOf[0]); - } - return schema; -}; - -const getSchemaNode = (path: Array): JsonSchema | undefined => { - let current: JsonSchema = getJsonSchema(); - - for (const segment of path) { - current = resolveSchema(current); - - if (typeof segment === 'number') { - if (current.items) { - current = current.items; - continue; - } - return undefined; - } - - if (current.properties && segment in current.properties) { - current = current.properties[segment]; - continue; - } - - return undefined; - } - - return current; -}; - -const getSchemaType = (schema: JsonSchema): SchemaPropertyInfo['type'] => { - const resolved = resolveSchema(schema); - const type = Array.isArray(resolved.type) ? resolved.type[0] : resolved.type; - - switch (type) { - case 'string': - return 'string'; - case 'number': - case 'integer': - return 'number'; - case 'boolean': - return 'boolean'; - case 'array': - return 'array'; - case 'object': - return 'object'; - default: - return 'unknown'; - } -}; - -const getSchemaProperties = (path: string[]): SchemaPropertyInfo[] => { - const node = getSchemaNode(path); - if (!node) return []; - - const resolved = resolveSchema(node); - if (!resolved.properties) return []; - - return Object.entries(resolved.properties).map(([key, propSchema]) => { - const resolvedProp = resolveSchema(propSchema); - const type = getSchemaType(propSchema); - const isEnum = Boolean(resolvedProp.enum); - const enumValues = isEnum ? (resolvedProp.enum as string[]) : undefined; - - return { - key, - description: propSchema.description ?? resolvedProp.description, - type, - isEnum, - enumValues, - }; - }); -}; - -const getSchemaDescription = (path: string[]): string | undefined => { - const node = getSchemaNode(path); - if (!node) return undefined; - const resolved = resolveSchema(node); - return node.description ?? resolved.description; -}; - -const getSchemaTypeInfo = ( - path: string[] -): { type: string; isBoolean: boolean; enumValues?: string[] } | undefined => { - const node = getSchemaNode(path); - if (!node) return undefined; - - const resolved = resolveSchema(node); - const type = Array.isArray(resolved.type) ? resolved.type[0] : resolved.type; - - if (type === 'boolean') { - return { type: 'boolean', isBoolean: true }; - } - if (resolved.enum) { - return { type: 'enum', isBoolean: false, enumValues: resolved.enum as string[] }; - } - - return { type: type ?? 'unknown', isBoolean: false }; -}; - -// ============================================================================ -// YAML Utilities -// ============================================================================ - -const getExistingYamlKeys = (text: string, parentPath: string[]): Set => { - const keys = new Set(); - const lines = text.split('\n'); - - if (parentPath.length === 0) { - for (const line of lines) { - const match = /^([A-Za-z0-9_]+)\s*:/.exec(line); - if (match) { - keys.add(match[1]); - } - } - } else { - let parentIndent = -1; - let inParent = false; - - for (const line of lines) { - const keyMatch = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(line); - - if (keyMatch) { - const key = keyMatch[2]; - const keyIndent = keyMatch[1].length; - - if (!inParent && key === parentPath[parentPath.length - 1] && keyIndent === 0) { - inParent = true; - parentIndent = keyIndent; - continue; - } - - if (inParent) { - if (keyIndent <= parentIndent && line.trim() !== '') { - break; - } - if (keyIndent === parentIndent + 2) { - keys.add(key); - } - } - } - } - } - - return keys; -}; - -const getCompletionContext = (text: string, position: monaco.Position) => { - const lines = text.split('\n'); - const lineIndex = Math.max(0, position.lineNumber - 1); - const line = lines[lineIndex] ?? ''; - const indent = line.match(/^\s*/)?.[0].length ?? 0; - const keyMatch = /^(\s*)([A-Za-z0-9_]+)\s*:(.*)$/.exec(line); - - if (keyMatch) { - const keyIndent = keyMatch[1].length; - const key = keyMatch[2]; - const hasValue = keyMatch[3].trim().length > 0; - const isValuePosition = hasValue || position.column > keyMatch[0].indexOf(':') + 1; - const parentPath = keyIndent === 0 ? [] : getYamlPathAtPosition(text, position)?.slice(0, -1); - return { - parentPath: parentPath ?? [], - currentKey: key, - isValuePosition, - }; - } - - if (indent === 0) { - return { parentPath: [], currentKey: null, isValuePosition: false }; - } - - for (let i = lineIndex - 1; i >= 0; i--) { - const match = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(lines[i]); - if (!match) { - continue; - } - const parentIndent = match[1].length; - if (parentIndent < indent) { - return { parentPath: [match[2]], currentKey: null, isValuePosition: false }; - } - } - - return { parentPath: [], currentKey: null, isValuePosition: false }; -}; - -const findYamlNodeForPath = (doc: YAML.Document, path: Array) => { - let node: YAML.Node | null | undefined = doc.contents; - let lastPair: YAML.Pair | null = null; - - for (const segment of path) { - if (YAML.isMap(node)) { - const pair = node.items.find( - (item) => YAML.isScalar(item.key) && item.key.value === segment - ) as YAML.Pair | undefined; - if (!pair) { - return { node: null, pair: null }; - } - lastPair = pair; - node = pair.value; - continue; - } - - if (YAML.isSeq(node) && typeof segment === 'number') { - const nextNode = node.items[segment] as YAML.Node | undefined; - if (!nextNode) { - return { node: null, pair: null }; - } - node = nextNode; - continue; - } - - return { node: null, pair: null }; - } - - return { node, pair: lastPair }; -}; - -const toMonacoPosition = (linePos: { line: number; col: number }) => { - return { - lineNumber: linePos.line > 0 ? linePos.line : 1, - column: linePos.col > 0 ? linePos.col : 1, - }; -}; - -const getRangeFromOffsets = (lineCounter: LineCounter, start: number, end: number) => { - const startPos = toMonacoPosition(lineCounter.linePos(start)); - const endPos = toMonacoPosition(lineCounter.linePos(end)); - return new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column); -}; - -const buildYamlValidationMarkers = (model: monaco.editor.ITextModel) => { - const text = model.getValue(); - const lineCounter = new LineCounter(); - const doc = YAML.parseDocument(text, { lineCounter }); - const markers: monaco.editor.IMarkerData[] = []; - - for (const error of doc.errors) { - const [start, end] = error.pos ?? [0, 0]; - const range = getRangeFromOffsets(lineCounter, start, Math.max(end, start + 1)); - markers.push({ - message: error.message, - severity: monaco.MarkerSeverity.Error, - startLineNumber: range.startLineNumber, - startColumn: range.startColumn, - endLineNumber: range.endLineNumber, - endColumn: range.endColumn, - }); - } - - if (doc.errors.length === 0) { - const parsed = createRuleDataSchema.safeParse(doc.toJS()); - if (!parsed.success) { - for (const issue of parsed.error.issues) { - const path = issue.path as Array; - const { node, pair } = findYamlNodeForPath(doc, path); - const rangeSource = pair?.key ?? node; - const range = rangeSource?.range - ? getRangeFromOffsets(lineCounter, rangeSource.range[0], rangeSource.range[1]) - : getRangeFromOffsets(lineCounter, 0, 1); - markers.push({ - message: issue.message, - severity: monaco.MarkerSeverity.Error, - startLineNumber: range.startLineNumber, - startColumn: range.startColumn, - endLineNumber: range.endLineNumber, - endColumn: range.endColumn, - }); - } - } - } - - monaco.editor.setModelMarkers(model, 'alertingV2YamlSchema', markers); -}; - -const getYamlPathAtPosition = (text: string, position: monaco.Position): string[] | null => { - const lines = text.split('\n'); - const lineIndex = Math.max(0, position.lineNumber - 1); - const line = lines[lineIndex] ?? ''; - const indent = line.match(/^\s*/)?.[0].length ?? 0; - const keyMatch = /^\s*([A-Za-z0-9_]+)\s*:/.exec(line); - - if (keyMatch) { - const key = keyMatch[1]; - if (indent === 0) { - return [key]; - } - for (let i = lineIndex - 1; i >= 0; i--) { - const parentLine = lines[i]; - const parentMatch = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(parentLine); - if (!parentMatch) { - continue; - } - const parentIndent = parentMatch[1].length; - if (parentIndent < indent) { - return [parentMatch[2], key]; - } - } - return [key]; - } - - const cursorOffset = lines.slice(0, lineIndex).reduce((acc, curr) => acc + curr.length + 1, 0); - const queryContext = findYamlQueryContext(text, cursorOffset + position.column - 1); - if (queryContext) { - return ['query']; - } - - for (let i = lineIndex - 1; i >= 0; i--) { - const parentLine = lines[i]; - const parentMatch = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(parentLine); - if (!parentMatch) { - continue; - } - const parentIndent = parentMatch[1].length; - if (parentIndent < indent) { - return [parentMatch[2]]; - } - } - - return null; -}; - -// ============================================================================ -// ES|QL Query Context Detection -// ============================================================================ - -const findYamlQueryContext = (text: string, cursorOffset: number): QueryContext | null => { - const lines = text.split('\n'); - const lineStartOffsets: number[] = []; - let runningOffset = 0; - let cursorLine = 0; - - for (let i = 0; i < lines.length; i++) { - lineStartOffsets.push(runningOffset); - if (cursorOffset >= runningOffset && cursorOffset <= runningOffset + lines[i].length) { - cursorLine = i; - } - runningOffset += lines[i].length + 1; - } - - for (let queryLineIdx = cursorLine; queryLineIdx >= 0; queryLineIdx--) { - const line = lines[queryLineIdx]; - const queryMatch = /^(\s*)query:\s*(.*)$/.exec(line); - if (!queryMatch) continue; - - const baseIndent = queryMatch[1].length; - const afterColon = queryMatch[2] ?? ''; - const trimmedAfterColon = afterColon.trim(); - - // Case 1: Block scalar (| or >) - if (trimmedAfterColon.startsWith('|') || trimmedAfterColon.startsWith('>')) { - let blockIndent = 0; - for (let j = queryLineIdx + 1; j < lines.length; j++) { - if (lines[j].trim() === '') continue; - const lineIndent = lines[j].match(/^\s*/)?.[0].length ?? 0; - if (lineIndent > baseIndent) { - blockIndent = lineIndent; - } - break; - } - - if (blockIndent === 0) { - if (cursorLine <= queryLineIdx) return null; - const cursorIndent = lines[cursorLine].match(/^\s*/)?.[0].length ?? 0; - blockIndent = Math.max(baseIndent + 2, cursorIndent); - } - - let endLine = queryLineIdx + 1; - while (endLine < lines.length) { - const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; - if (lines[endLine].trim() !== '' && lineIndent < blockIndent) break; - endLine++; - } - - if (cursorLine <= queryLineIdx || cursorLine >= endLine) return null; - - const queryLines = lines - .slice(queryLineIdx + 1, endLine) - .map((lineText) => (lineText.length >= blockIndent ? lineText.slice(blockIndent) : '')); - const queryText = queryLines.join('\n'); - - const cursorLineInQuery = cursorLine - (queryLineIdx + 1); - const cursorColInLine = Math.max( - 0, - cursorOffset - lineStartOffsets[cursorLine] - blockIndent - ); - const offsetBeforeCursorLine = queryLines - .slice(0, cursorLineInQuery) - .reduce((acc, l) => acc + l.length + 1, 0); - - return { - queryText, - queryOffset: Math.max( - 0, - Math.min(queryText.length, offsetBeforeCursorLine + cursorColInLine) - ), - }; - } - - // Case 2a: Empty value but cursor is on the query line - if (trimmedAfterColon.length === 0 && cursorLine === queryLineIdx) { - const colonIdx = line.indexOf(':'); - const valueStartCol = colonIdx + 1; - const valueStartOffset = lineStartOffsets[queryLineIdx] + valueStartCol; - - if (cursorOffset >= valueStartOffset) { - return { queryText: '', queryOffset: 0 }; - } - return null; - } - - // Case 2b: Inline value (possibly with continuation lines) - if (trimmedAfterColon.length > 0) { - const valueStartCol = line.length - afterColon.length; - const valueStartOffset = lineStartOffsets[queryLineIdx] + valueStartCol; - - const quote = afterColon.startsWith('"') || afterColon.startsWith("'") ? afterColon[0] : null; - const closingQuoteIdx = quote ? afterColon.lastIndexOf(quote) : -1; - const queryStartOffset = valueStartOffset + (quote ? 1 : 0); - const queryEndOffset = - closingQuoteIdx > 0 - ? valueStartOffset + closingQuoteIdx - : valueStartOffset + afterColon.length; - - let continuationIndent = 0; - for (let j = queryLineIdx + 1; j < lines.length; j++) { - if (lines[j].trim() === '') continue; - const lineIndent = lines[j].match(/^\s*/)?.[0].length ?? 0; - if (lineIndent > baseIndent) { - continuationIndent = lineIndent; - } - break; - } - - if (continuationIndent === 0) { - if (cursorLine !== queryLineIdx) return null; - if (cursorOffset < queryStartOffset || cursorOffset > queryEndOffset) return null; - - const queryText = afterColon.slice( - quote ? 1 : 0, - closingQuoteIdx > 0 ? closingQuoteIdx : undefined - ); - return { - queryText, - queryOffset: Math.max(0, Math.min(queryText.length, cursorOffset - queryStartOffset)), - }; - } - - let endLine = queryLineIdx + 1; - while (endLine < lines.length) { - const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; - if (lines[endLine].trim() !== '' && lineIndent <= baseIndent) break; - endLine++; - } - - if (cursorLine < queryLineIdx || cursorLine >= endLine) return null; - - const firstLineText = afterColon.slice( - quote ? 1 : 0, - closingQuoteIdx > 0 ? closingQuoteIdx : undefined - ); - const continuationLines = lines.slice(queryLineIdx + 1, endLine).map((lineText) => { - const lineIndent = lineText.match(/^\s*/)?.[0].length ?? 0; - return lineIndent >= continuationIndent ? lineText.slice(continuationIndent) : ''; - }); - const queryText = [firstLineText, ...continuationLines].join('\n'); - - if (cursorLine === queryLineIdx) { - if (cursorOffset < queryStartOffset) return null; - return { - queryText, - queryOffset: Math.max(0, Math.min(firstLineText.length, cursorOffset - queryStartOffset)), - }; - } - - const cursorLineInQuery = cursorLine - (queryLineIdx + 1); - const cursorColInLine = Math.max( - 0, - cursorOffset - lineStartOffsets[cursorLine] - continuationIndent - ); - const offsetBeforeCursorLine = - firstLineText.length + - 1 + - continuationLines.slice(0, cursorLineInQuery).reduce((acc, l) => acc + l.length + 1, 0); - - return { - queryText, - queryOffset: Math.max( - 0, - Math.min(queryText.length, offsetBeforeCursorLine + cursorColInLine) - ), - }; - } - - // Case 3: Empty value - check if cursor is on a continuation line - if (cursorLine <= queryLineIdx) return null; - - let blockIndent = 0; - for (let j = queryLineIdx + 1; j < lines.length; j++) { - if (lines[j].trim() === '') continue; - const lineIndent = lines[j].match(/^\s*/)?.[0].length ?? 0; - if (lineIndent > baseIndent) { - blockIndent = lineIndent; - } - break; - } - - if (blockIndent === 0) { - const cursorIndent = lines[cursorLine].match(/^\s*/)?.[0].length ?? 0; - blockIndent = Math.max(baseIndent + 2, cursorIndent); - } - - let endLine = queryLineIdx + 1; - while (endLine < lines.length) { - const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; - if (lines[endLine].trim() !== '' && lineIndent < blockIndent) break; - endLine++; - } - - if (cursorLine >= endLine) return null; - - const queryLines = lines - .slice(queryLineIdx + 1, endLine) - .map((lineText) => (lineText.length >= blockIndent ? lineText.slice(blockIndent) : '')); - const queryText = queryLines.join('\n'); - - const cursorLineInQuery = cursorLine - (queryLineIdx + 1); - const cursorColInLine = Math.max(0, cursorOffset - lineStartOffsets[cursorLine] - blockIndent); - const offsetBeforeCursorLine = queryLines - .slice(0, cursorLineInQuery) - .reduce((acc, l) => acc + l.length + 1, 0); - - return { - queryText, - queryOffset: Math.max( - 0, - Math.min(queryText.length, offsetBeforeCursorLine + cursorColInLine) - ), - }; - } - - return null; -}; - -// ============================================================================ -// Monaco Language Registration -// ============================================================================ - -const ALERTING_V2_YAML_ESQL_LANG_ID = 'alertingV2YamlEsql'; - -class AlertingYamlState implements monaco.languages.IState { - constructor( - public readonly kind: 'none' | 'pending' | 'block' | 'inline', - public readonly baseIndent: number, - public readonly blockIndent: number - ) {} - - clone() { - return new AlertingYamlState(this.kind, this.baseIndent, this.blockIndent); - } - - equals(other: monaco.languages.IState) { - if (!(other instanceof AlertingYamlState)) { - return false; - } - return ( - other.kind === this.kind && - other.baseIndent === this.baseIndent && - other.blockIndent === this.blockIndent - ); - } -} - -const ensureAlertingYamlLanguage = () => { - const languages = monaco.languages.getLanguages(); - if (languages.some(({ id }) => id === ALERTING_V2_YAML_ESQL_LANG_ID)) { - return; - } - - void ESQLLang.onLanguage?.(); - - monaco.languages.register({ id: ALERTING_V2_YAML_ESQL_LANG_ID }); - if (YamlLang.languageConfiguration) { - monaco.languages.setLanguageConfiguration( - ALERTING_V2_YAML_ESQL_LANG_ID, - YamlLang.languageConfiguration - ); - } - - const normalizeEsqlTokenType = (tokenType: string) => { - const [base] = tokenType.split('.'); - return base || tokenType; - }; - - const toMonacoTokens = ( - tokens: monaco.Token[] | undefined, - transform?: (tokenType: string) => string - ): monaco.languages.IToken[] => { - if (!tokens) { - return []; - } - return tokens.map((token) => ({ - startIndex: token.offset, - scopes: transform ? transform(token.type) : token.type, - })); - }; - - const tokenizeYamlLine = (line: string) => { - const yamlTokens = monaco.editor.tokenize(line, YAML_LANG_ID)[0]; - return toMonacoTokens(yamlTokens); - }; - - const tokenizeEsqlLine = (line: string, offset: number) => { - const esqlTokens = monaco.editor.tokenize(line, ESQL_LANG_ID)[0]; - return toMonacoTokens(esqlTokens, normalizeEsqlTokenType).map((token) => ({ - startIndex: token.startIndex + offset, - scopes: token.scopes, - })); - }; - - monaco.languages.setTokensProvider(ALERTING_V2_YAML_ESQL_LANG_ID, { - getInitialState: () => new AlertingYamlState('none', 0, 0), - tokenize: (line, state) => { - if (!(state instanceof AlertingYamlState)) { - return { tokens: tokenizeYamlLine(line), endState: new AlertingYamlState('none', 0, 0) }; - } - - const indent = line.match(/^\s*/)?.[0].length ?? 0; - const trimmed = line.trim(); - - if (state.kind === 'pending') { - if (trimmed === '') { - return { tokens: tokenizeYamlLine(line), endState: state }; - } - - if (indent > state.baseIndent) { - const esqlText = line.slice(indent); - const tokens = [ - { startIndex: 0, scopes: 'source.yaml' }, - ...tokenizeEsqlLine(esqlText, indent), - ]; - return { - tokens, - endState: new AlertingYamlState('block', state.baseIndent, indent), - }; - } - - return { - tokens: tokenizeYamlLine(line), - endState: new AlertingYamlState('none', 0, 0), - }; - } - - if (state.kind === 'block') { - if (trimmed !== '' && indent < state.blockIndent) { - return { - tokens: tokenizeYamlLine(line), - endState: new AlertingYamlState('none', 0, 0), - }; - } - - const esqlText = indent >= state.blockIndent ? line.slice(state.blockIndent) : ''; - const tokens = [ - { startIndex: 0, scopes: 'source.yaml' }, - ...tokenizeEsqlLine(esqlText, state.blockIndent), - ]; - return { - tokens, - endState: state, - }; - } - - if (state.kind === 'inline') { - if (trimmed === '' && indent <= state.baseIndent) { - return { - tokens: tokenizeYamlLine(line), - endState: new AlertingYamlState('none', 0, 0), - }; - } - - if (trimmed !== '' && indent <= state.baseIndent) { - return { - tokens: tokenizeYamlLine(line), - endState: new AlertingYamlState('none', 0, 0), - }; - } - - const continuationIndent = - state.blockIndent > 0 ? state.blockIndent : indent > state.baseIndent ? indent : 0; - - if (continuationIndent === 0) { - return { - tokens: tokenizeYamlLine(line), - endState: new AlertingYamlState('none', 0, 0), - }; - } - - const esqlText = indent >= continuationIndent ? line.slice(continuationIndent) : ''; - const tokens = [ - { startIndex: 0, scopes: 'source.yaml' }, - ...tokenizeEsqlLine(esqlText, continuationIndent), - ]; - return { - tokens, - endState: new AlertingYamlState('inline', state.baseIndent, continuationIndent), - }; - } - - const queryMatch = /^(\s*)(query)(:)\s*(.*)$/.exec(line); - if (!queryMatch) { - return { tokens: tokenizeYamlLine(line), endState: state }; - } - - const baseIndent = queryMatch[1].length; - const keyStart = baseIndent; - const keyEnd = keyStart + queryMatch[2].length; - const colonPos = keyEnd; - const value = queryMatch[4] ?? ''; - const valueStartIndex = colonPos + 1 + (line.slice(colonPos + 1).length - value.length); - const trimmedValue = value.trim(); - - const baseTokens: monaco.languages.IToken[] = [ - { startIndex: 0, scopes: 'source.yaml' }, - { startIndex: keyStart, scopes: 'type.yaml' }, - { startIndex: colonPos, scopes: 'operators.yaml' }, - ]; - - if (trimmedValue.startsWith('>') || trimmedValue.startsWith('|')) { - const yamlTokens = tokenizeYamlLine(line); - return { - tokens: yamlTokens, - endState: new AlertingYamlState('pending', baseIndent, 0), - }; - } - - if (trimmedValue.length > 0) { - const rawValue = value.trimStart(); - const rawValueOffset = valueStartIndex + (value.length - rawValue.length); - const quote = rawValue.startsWith('"') || rawValue.startsWith("'") ? rawValue[0] : null; - const closingIndex = quote ? rawValue.lastIndexOf(quote) : rawValue.length; - const queryText = rawValue.slice( - quote ? 1 : 0, - closingIndex > 0 ? closingIndex : undefined - ); - const queryOffset = rawValueOffset + (quote ? 1 : 0); - const tokens = [...baseTokens, ...tokenizeEsqlLine(queryText, queryOffset)]; - return { tokens, endState: new AlertingYamlState('inline', baseIndent, 0) }; - } - - return { tokens: baseTokens, endState: new AlertingYamlState('pending', baseIndent, 0) }; - }, - }); -}; - -// Initialize language on module load -ensureAlertingYamlLanguage(); - -// ============================================================================ -// Completion Items Conversion -// ============================================================================ - -const toCompletionItems = ( - suggestions: Array<{ - label: string; - text: string; - asSnippet?: boolean; - kind?: string; - detail?: string; - documentation?: string | { value: string }; - sortText?: string; - filterText?: string; - }>, - range: monaco.Range -): monaco.languages.CompletionItem[] => { - return suggestions.map((item) => { - const kind = - item.kind && item.kind in monaco.languages.CompletionItemKind - ? monaco.languages.CompletionItemKind[ - item.kind as keyof typeof monaco.languages.CompletionItemKind - ] - : monaco.languages.CompletionItemKind.Method; - return { - label: item.label, - insertText: item.text, - filterText: item.filterText, - kind, - detail: item.detail, - documentation: item.documentation, - sortText: item.sortText, - insertTextRules: item.asSnippet - ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet - : undefined, - range, - }; - }); -}; - -// ============================================================================ -// YamlRuleEditor Component -// ============================================================================ - -export const YamlRuleEditor: React.FC = ({ - value, - onChange, - esqlCallbacks, - isReadOnly = false, - height = 320, - dataTestSubj = 'alertingV2YamlRuleEditor', -}) => { - const editorSuggestDisposable = useRef(null); - const editorValidationDisposable = useRef(null); - - const suggestionProvider = useMemo( - () => ({ - triggerCharacters: [...ESQL_AUTOCOMPLETE_TRIGGER_CHARS, ':', ' '], - provideCompletionItems: async (model, position) => { - const fullText = model.getValue(); - const cursorOffset = model.getOffsetAt(position); - const queryContext = findYamlQueryContext(fullText, cursorOffset); - - const word = model.getWordUntilPosition(position); - const range = new monaco.Range( - position.lineNumber, - word.startColumn, - position.lineNumber, - word.endColumn - ); - - if (queryContext) { - const suggestions = await suggest( - queryContext.queryText, - queryContext.queryOffset, - esqlCallbacks - ); - return { - suggestions: toCompletionItems(suggestions, range), - }; - } - - const completionContext = getCompletionContext(fullText, position); - if (!completionContext) { - return { suggestions: [] }; - } - - if (completionContext.isValuePosition && completionContext.currentKey) { - const typeInfo = getSchemaTypeInfo([ - ...completionContext.parentPath, - completionContext.currentKey, - ]); - if (typeInfo?.isBoolean) { - return { - suggestions: ['true', 'false'].map((val) => ({ - label: val, - insertText: val, - kind: monaco.languages.CompletionItemKind.Value, - range, - })), - }; - } - if (typeInfo?.enumValues) { - return { - suggestions: typeInfo.enumValues.map((val) => ({ - label: val, - insertText: val, - kind: monaco.languages.CompletionItemKind.Value, - range, - })), - }; - } - return { suggestions: [] }; - } - - const properties = getSchemaProperties(completionContext.parentPath); - const existingKeys = getExistingYamlKeys(fullText, completionContext.parentPath); - - return { - suggestions: properties - .filter(({ key }) => !existingKeys.has(key)) - .map(({ key, description }) => ({ - label: key, - insertText: `${key}: `, - kind: monaco.languages.CompletionItemKind.Property, - documentation: description ? { value: description } : undefined, - range, - })), - }; - }, - }), - [esqlCallbacks] - ); - - const hoverProvider = useMemo( - () => ({ - provideHover: (model, position) => { - const path = getYamlPathAtPosition(model.getValue(), position); - if (!path) { - return null; - } - const description = getSchemaDescription(path); - if (!description) { - return null; - } - return { - contents: [{ value: description }], - }; - }, - }), - [] - ); - - const handleEditorDidMount = useCallback((editor: monaco.editor.IStandaloneCodeEditor) => { - const model = editor.getModel(); - if (!model) { - return; - } - - // Force tokenization on initial render for the custom YAML+ESQL language - monaco.editor.setModelLanguage(model, YAML_LANG_ID); - monaco.editor.setModelLanguage(model, ALERTING_V2_YAML_ESQL_LANG_ID); - - editorSuggestDisposable.current?.dispose(); - editorSuggestDisposable.current = editor.onDidChangeModelContent(() => { - const position = editor.getPosition(); - const currentModel = editor.getModel(); - if (!position || !currentModel) { - return; - } - const offset = currentModel.getOffsetAt(position); - if (findYamlQueryContext(currentModel.getValue(), offset)) { - editor.trigger('alerting_v2', 'editor.action.triggerSuggest', null); - } - }); - - buildYamlValidationMarkers(model); - editorValidationDisposable.current?.dispose(); - editorValidationDisposable.current = editor.onDidChangeModelContent(() => { - buildYamlValidationMarkers(model); - }); - }, []); - - const handleEditorWillUnmount = useCallback(() => { - editorSuggestDisposable.current?.dispose(); - editorSuggestDisposable.current = null; - editorValidationDisposable.current?.dispose(); - editorValidationDisposable.current = null; - }, []); - - return ( - - ); -}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/index.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/index.ts new file mode 100644 index 0000000000000..46e53d6955212 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/index.ts @@ -0,0 +1,10 @@ +/* + * 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 { YamlRuleEditor } from './yaml_rule_editor'; +export { DEFAULT_ESQL_PROPERTY_NAMES } from './types'; +export type { YamlRuleEditorProps, QueryContext, SchemaPropertyInfo } from './types'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/monaco_language.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/monaco_language.ts new file mode 100644 index 0000000000000..6ce328659ab7a --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/monaco_language.ts @@ -0,0 +1,316 @@ +/* + * 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 { ESQL_LANG_ID, ESQLLang, monaco, YamlLang, YAML_LANG_ID } from '@kbn/monaco'; +import { DEFAULT_ESQL_PROPERTY_NAMES } from './types'; + +/** + * Custom language ID for YAML with embedded ES|QL + */ +export const ALERTING_V2_YAML_ESQL_LANG_ID = 'alertingV2YamlEsql'; + +/** + * Module-level configuration for ES|QL property names used by the tokenizer + */ +let currentEsqlPropertyNames: string[] = DEFAULT_ESQL_PROPERTY_NAMES; +let currentEsqlPropertyPattern: RegExp = createEsqlPropertyPattern(DEFAULT_ESQL_PROPERTY_NAMES); + +/** + * Create a regex pattern to match any of the ES|QL property names for tokenization + */ +function createEsqlPropertyPattern(propertyNames: string[]): RegExp { + const escapedNames = propertyNames.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + return new RegExp(`^(\\s*)(${escapedNames.join('|')})(:)\\s*(.*)$`); +} + +/** + * Update the ES|QL property names used by the tokenizer. + * Call this when the editor's esqlPropertyNames prop changes. + */ +export const setEsqlPropertyNames = (propertyNames: string[]): void => { + if ( + propertyNames.length === currentEsqlPropertyNames.length && + propertyNames.every((name, i) => name === currentEsqlPropertyNames[i]) + ) { + return; // No change + } + currentEsqlPropertyNames = propertyNames; + currentEsqlPropertyPattern = createEsqlPropertyPattern(propertyNames); +}; + +/** + * State class for the custom YAML+ES|QL tokenizer + */ +class AlertingYamlState implements monaco.languages.IState { + constructor( + public readonly kind: 'none' | 'pending' | 'block' | 'inline', + public readonly baseIndent: number, + public readonly blockIndent: number + ) {} + + clone() { + return new AlertingYamlState(this.kind, this.baseIndent, this.blockIndent); + } + + equals(other: monaco.languages.IState) { + if (!(other instanceof AlertingYamlState)) { + return false; + } + return ( + other.kind === this.kind && + other.baseIndent === this.baseIndent && + other.blockIndent === this.blockIndent + ); + } +} + +/** + * Normalize ES|QL token type by extracting the base type + */ +const normalizeEsqlTokenType = (tokenType: string) => { + const [base] = tokenType.split('.'); + return base || tokenType; +}; + +/** + * Convert Monaco tokens to the format expected by the tokenizer + */ +const toMonacoTokens = ( + tokens: monaco.Token[] | undefined, + transform?: (tokenType: string) => string +): monaco.languages.IToken[] => { + if (!tokens) { + return []; + } + return tokens.map((token) => ({ + startIndex: token.offset, + scopes: transform ? transform(token.type) : token.type, + })); +}; + +/** + * Tokenize a line as YAML + */ +const tokenizeYamlLine = (line: string) => { + const yamlTokens = monaco.editor.tokenize(line, YAML_LANG_ID)[0]; + return toMonacoTokens(yamlTokens); +}; + +/** + * Tokenize a line as ES|QL + */ +const tokenizeEsqlLine = (line: string, offset: number) => { + const esqlTokens = monaco.editor.tokenize(line, ESQL_LANG_ID)[0]; + return toMonacoTokens(esqlTokens, normalizeEsqlTokenType).map((token) => ({ + startIndex: token.startIndex + offset, + scopes: token.scopes, + })); +}; + +/** + * Tokenize a line in pending state (waiting for block content) + */ +function tokenizePendingState( + line: string, + state: AlertingYamlState, + indent: number, + trimmed: string +): { tokens: monaco.languages.IToken[]; endState: AlertingYamlState } { + if (trimmed === '') { + return { tokens: tokenizeYamlLine(line), endState: state }; + } + + if (indent > state.baseIndent) { + const esqlText = line.slice(indent); + const tokens = [ + { startIndex: 0, scopes: 'source.yaml' }, + ...tokenizeEsqlLine(esqlText, indent), + ]; + return { + tokens, + endState: new AlertingYamlState('block', state.baseIndent, indent), + }; + } + + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('none', 0, 0), + }; +} + +/** + * Tokenize a line in block state (inside block scalar) + */ +function tokenizeBlockState( + line: string, + state: AlertingYamlState, + indent: number, + trimmed: string +): { tokens: monaco.languages.IToken[]; endState: AlertingYamlState } { + if (trimmed !== '' && indent < state.blockIndent) { + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('none', 0, 0), + }; + } + + const esqlText = indent >= state.blockIndent ? line.slice(state.blockIndent) : ''; + const tokens = [ + { startIndex: 0, scopes: 'source.yaml' }, + ...tokenizeEsqlLine(esqlText, state.blockIndent), + ]; + return { + tokens, + endState: state, + }; +} + +/** + * Tokenize a line in inline state (continuation of inline value) + */ +function tokenizeInlineState( + line: string, + state: AlertingYamlState, + indent: number, + trimmed: string +): { tokens: monaco.languages.IToken[]; endState: AlertingYamlState } { + if (trimmed === '' && indent <= state.baseIndent) { + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('none', 0, 0), + }; + } + + if (trimmed !== '' && indent <= state.baseIndent) { + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('none', 0, 0), + }; + } + + const continuationIndent = + state.blockIndent > 0 ? state.blockIndent : indent > state.baseIndent ? indent : 0; + + if (continuationIndent === 0) { + return { + tokens: tokenizeYamlLine(line), + endState: new AlertingYamlState('none', 0, 0), + }; + } + + const esqlText = indent >= continuationIndent ? line.slice(continuationIndent) : ''; + const tokens = [ + { startIndex: 0, scopes: 'source.yaml' }, + ...tokenizeEsqlLine(esqlText, continuationIndent), + ]; + return { + tokens, + endState: new AlertingYamlState('inline', state.baseIndent, continuationIndent), + }; +} + +/** + * Tokenize an ES|QL property line with the key, colon, and value + */ +function tokenizeEsqlPropertyLine( + line: string, + queryMatch: RegExpExecArray +): { tokens: monaco.languages.IToken[]; endState: AlertingYamlState } { + const baseIndent = queryMatch[1].length; + const keyStart = baseIndent; + const keyEnd = keyStart + queryMatch[2].length; + const colonPos = keyEnd; + const value = queryMatch[4] ?? ''; + const valueStartIndex = colonPos + 1 + (line.slice(colonPos + 1).length - value.length); + const trimmedValue = value.trim(); + + const baseTokens: monaco.languages.IToken[] = [ + { startIndex: 0, scopes: 'source.yaml' }, + { startIndex: keyStart, scopes: 'type.yaml' }, + { startIndex: colonPos, scopes: 'operators.yaml' }, + ]; + + if (trimmedValue.startsWith('>') || trimmedValue.startsWith('|')) { + const yamlTokens = tokenizeYamlLine(line); + return { + tokens: yamlTokens, + endState: new AlertingYamlState('pending', baseIndent, 0), + }; + } + + if (trimmedValue.length > 0) { + const rawValue = value.trimStart(); + const rawValueOffset = valueStartIndex + (value.length - rawValue.length); + const quote = rawValue.startsWith('"') || rawValue.startsWith("'") ? rawValue[0] : null; + const closingIndex = quote ? rawValue.lastIndexOf(quote) : rawValue.length; + const queryText = rawValue.slice(quote ? 1 : 0, closingIndex > 0 ? closingIndex : undefined); + const queryOffset = rawValueOffset + (quote ? 1 : 0); + const tokens = [...baseTokens, ...tokenizeEsqlLine(queryText, queryOffset)]; + return { tokens, endState: new AlertingYamlState('inline', baseIndent, 0) }; + } + + return { tokens: baseTokens, endState: new AlertingYamlState('pending', baseIndent, 0) }; +} + +/** + * Create the tokens provider for the custom YAML+ES|QL language + */ +const createTokensProvider = (): monaco.languages.TokensProvider => ({ + getInitialState: () => new AlertingYamlState('none', 0, 0), + tokenize: (line, state) => { + if (!(state instanceof AlertingYamlState)) { + return { tokens: tokenizeYamlLine(line), endState: new AlertingYamlState('none', 0, 0) }; + } + + const indent = line.match(/^\s*/)?.[0].length ?? 0; + const trimmed = line.trim(); + + if (state.kind === 'pending') { + return tokenizePendingState(line, state, indent, trimmed); + } + + if (state.kind === 'block') { + return tokenizeBlockState(line, state, indent, trimmed); + } + + if (state.kind === 'inline') { + return tokenizeInlineState(line, state, indent, trimmed); + } + + const queryMatch = currentEsqlPropertyPattern.exec(line); + if (!queryMatch) { + return { tokens: tokenizeYamlLine(line), endState: state }; + } + + return tokenizeEsqlPropertyLine(line, queryMatch); + }, +}); + +/** + * Ensure the custom YAML+ES|QL language is registered with Monaco + */ +export const ensureAlertingYamlLanguage = () => { + const languages = monaco.languages.getLanguages(); + if (languages.some(({ id }) => id === ALERTING_V2_YAML_ESQL_LANG_ID)) { + return; + } + + void ESQLLang.onLanguage?.(); + + monaco.languages.register({ id: ALERTING_V2_YAML_ESQL_LANG_ID }); + if (YamlLang.languageConfiguration) { + monaco.languages.setLanguageConfiguration( + ALERTING_V2_YAML_ESQL_LANG_ID, + YamlLang.languageConfiguration + ); + } + + monaco.languages.setTokensProvider(ALERTING_V2_YAML_ESQL_LANG_ID, createTokensProvider()); +}; + +// Initialize language on module load +ensureAlertingYamlLanguage(); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/query_context.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/query_context.ts new file mode 100644 index 0000000000000..ac3cbfb2c4f02 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/query_context.ts @@ -0,0 +1,328 @@ +/* + * 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 type { QueryContext } from './types'; +import { DEFAULT_ESQL_PROPERTY_NAMES } from './types'; + +/** + * Create a regex pattern to match any of the ES|QL property names + */ +const createEsqlPropertyPattern = (propertyNames: string[]): RegExp => { + const escapedNames = propertyNames.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + return new RegExp(`^(\\s*)(${escapedNames.join('|')}):\\s*(.*)$`); +}; + +/** + * Find the ES|QL query context at the cursor position. + * Handles inline queries, block scalar queries (| or >), and multi-line continuation. + * + * @param text - The full YAML text + * @param cursorOffset - The cursor offset in the text + * @param esqlPropertyNames - Property names that should be treated as ES|QL queries + * @returns QueryContext if cursor is within a query, null otherwise + */ +export const findYamlQueryContext = ( + text: string, + cursorOffset: number, + esqlPropertyNames: string[] = DEFAULT_ESQL_PROPERTY_NAMES +): QueryContext | null => { + const lines = text.split('\n'); + const lineStartOffsets: number[] = []; + let runningOffset = 0; + let cursorLine = 0; + + for (let i = 0; i < lines.length; i++) { + lineStartOffsets.push(runningOffset); + if (cursorOffset >= runningOffset && cursorOffset <= runningOffset + lines[i].length) { + cursorLine = i; + } + runningOffset += lines[i].length + 1; + } + + const esqlPattern = createEsqlPropertyPattern(esqlPropertyNames); + + for (let queryLineIdx = cursorLine; queryLineIdx >= 0; queryLineIdx--) { + const line = lines[queryLineIdx]; + const queryMatch = esqlPattern.exec(line); + if (!queryMatch) continue; + + const baseIndent = queryMatch[1].length; + const propertyName = queryMatch[2]; + const afterColon = queryMatch[3] ?? ''; + const trimmedAfterColon = afterColon.trim(); + + // Case 1: Block scalar (| or >) + const blockScalarResult = handleBlockScalar( + lines, + lineStartOffsets, + queryLineIdx, + baseIndent, + trimmedAfterColon, + cursorLine, + cursorOffset, + propertyName + ); + if (blockScalarResult !== undefined) { + return blockScalarResult; + } + + // Case 2a: Empty value but cursor is on the query line + if (trimmedAfterColon.length === 0 && cursorLine === queryLineIdx) { + const colonIdx = line.indexOf(':'); + const valueStartCol = colonIdx + 1; + const valueStartOffset = lineStartOffsets[queryLineIdx] + valueStartCol; + + if (cursorOffset >= valueStartOffset) { + return { propertyName, queryText: '', queryOffset: 0 }; + } + return null; + } + + // Case 2b: Inline value (possibly with continuation lines) + if (trimmedAfterColon.length > 0) { + const inlineResult = handleInlineValue( + lines, + lineStartOffsets, + queryLineIdx, + line, + baseIndent, + afterColon, + cursorLine, + cursorOffset, + propertyName + ); + if (inlineResult !== undefined) { + return inlineResult; + } + } + + // Case 3: Empty value - check if cursor is on a continuation line + const emptyValueResult = handleEmptyValue( + lines, + lineStartOffsets, + queryLineIdx, + baseIndent, + cursorLine, + cursorOffset, + propertyName + ); + if (emptyValueResult !== undefined) { + return emptyValueResult; + } + } + + return null; +}; + +/** + * Handle block scalar (| or >) query values + */ +function handleBlockScalar( + lines: string[], + lineStartOffsets: number[], + queryLineIdx: number, + baseIndent: number, + trimmedAfterColon: string, + cursorLine: number, + cursorOffset: number, + propertyName: string +): QueryContext | null | undefined { + if (!trimmedAfterColon.startsWith('|') && !trimmedAfterColon.startsWith('>')) { + return undefined; // Not a block scalar, continue to next case + } + + let blockIndent = 0; + for (let j = queryLineIdx + 1; j < lines.length; j++) { + if (lines[j].trim() === '') continue; + const lineIndent = lines[j].match(/^\s*/)?.[0].length ?? 0; + if (lineIndent > baseIndent) { + blockIndent = lineIndent; + } + break; + } + + if (blockIndent === 0) { + if (cursorLine <= queryLineIdx) return null; + const cursorIndent = lines[cursorLine].match(/^\s*/)?.[0].length ?? 0; + blockIndent = Math.max(baseIndent + 2, cursorIndent); + } + + let endLine = queryLineIdx + 1; + while (endLine < lines.length) { + const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; + if (lines[endLine].trim() !== '' && lineIndent < blockIndent) break; + endLine++; + } + + if (cursorLine <= queryLineIdx || cursorLine >= endLine) return null; + + const queryLines = lines + .slice(queryLineIdx + 1, endLine) + .map((lineText) => (lineText.length >= blockIndent ? lineText.slice(blockIndent) : '')); + const queryText = queryLines.join('\n'); + + const cursorLineInQuery = cursorLine - (queryLineIdx + 1); + const cursorColInLine = Math.max(0, cursorOffset - lineStartOffsets[cursorLine] - blockIndent); + const offsetBeforeCursorLine = queryLines + .slice(0, cursorLineInQuery) + .reduce((acc, l) => acc + l.length + 1, 0); + + return { + propertyName, + queryText, + queryOffset: Math.max(0, Math.min(queryText.length, offsetBeforeCursorLine + cursorColInLine)), + }; +} + +/** + * Handle inline query values (possibly with continuation lines) + */ +function handleInlineValue( + lines: string[], + lineStartOffsets: number[], + queryLineIdx: number, + line: string, + baseIndent: number, + afterColon: string, + cursorLine: number, + cursorOffset: number, + propertyName: string +): QueryContext | null | undefined { + const valueStartCol = line.length - afterColon.length; + const valueStartOffset = lineStartOffsets[queryLineIdx] + valueStartCol; + + const quote = afterColon.startsWith('"') || afterColon.startsWith("'") ? afterColon[0] : null; + const closingQuoteIdx = quote ? afterColon.lastIndexOf(quote) : -1; + const queryStartOffset = valueStartOffset + (quote ? 1 : 0); + const queryEndOffset = + closingQuoteIdx > 0 ? valueStartOffset + closingQuoteIdx : valueStartOffset + afterColon.length; + + let continuationIndent = 0; + for (let j = queryLineIdx + 1; j < lines.length; j++) { + if (lines[j].trim() === '') continue; + const lineIndent = lines[j].match(/^\s*/)?.[0].length ?? 0; + if (lineIndent > baseIndent) { + continuationIndent = lineIndent; + } + break; + } + + if (continuationIndent === 0) { + if (cursorLine !== queryLineIdx) return null; + if (cursorOffset < queryStartOffset || cursorOffset > queryEndOffset) return null; + + const queryText = afterColon.slice( + quote ? 1 : 0, + closingQuoteIdx > 0 ? closingQuoteIdx : undefined + ); + return { + propertyName, + queryText, + queryOffset: Math.max(0, Math.min(queryText.length, cursorOffset - queryStartOffset)), + }; + } + + let endLine = queryLineIdx + 1; + while (endLine < lines.length) { + const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; + if (lines[endLine].trim() !== '' && lineIndent <= baseIndent) break; + endLine++; + } + + if (cursorLine < queryLineIdx || cursorLine >= endLine) return null; + + const firstLineText = afterColon.slice( + quote ? 1 : 0, + closingQuoteIdx > 0 ? closingQuoteIdx : undefined + ); + const continuationLines = lines.slice(queryLineIdx + 1, endLine).map((lineText) => { + const lineIndent = lineText.match(/^\s*/)?.[0].length ?? 0; + return lineIndent >= continuationIndent ? lineText.slice(continuationIndent) : ''; + }); + const queryText = [firstLineText, ...continuationLines].join('\n'); + + if (cursorLine === queryLineIdx) { + if (cursorOffset < queryStartOffset) return null; + return { + propertyName, + queryText, + queryOffset: Math.max(0, Math.min(firstLineText.length, cursorOffset - queryStartOffset)), + }; + } + + const cursorLineInQuery = cursorLine - (queryLineIdx + 1); + const cursorColInLine = Math.max( + 0, + cursorOffset - lineStartOffsets[cursorLine] - continuationIndent + ); + const offsetBeforeCursorLine = + firstLineText.length + + 1 + + continuationLines.slice(0, cursorLineInQuery).reduce((acc, l) => acc + l.length + 1, 0); + + return { + propertyName, + queryText, + queryOffset: Math.max(0, Math.min(queryText.length, offsetBeforeCursorLine + cursorColInLine)), + }; +} + +/** + * Handle empty value - check if cursor is on a continuation line + */ +function handleEmptyValue( + lines: string[], + lineStartOffsets: number[], + queryLineIdx: number, + baseIndent: number, + cursorLine: number, + cursorOffset: number, + propertyName: string +): QueryContext | null | undefined { + if (cursorLine <= queryLineIdx) return null; + + let blockIndent = 0; + for (let j = queryLineIdx + 1; j < lines.length; j++) { + if (lines[j].trim() === '') continue; + const lineIndent = lines[j].match(/^\s*/)?.[0].length ?? 0; + if (lineIndent > baseIndent) { + blockIndent = lineIndent; + } + break; + } + + if (blockIndent === 0) { + const cursorIndent = lines[cursorLine].match(/^\s*/)?.[0].length ?? 0; + blockIndent = Math.max(baseIndent + 2, cursorIndent); + } + + let endLine = queryLineIdx + 1; + while (endLine < lines.length) { + const lineIndent = lines[endLine].match(/^\s*/)?.[0].length ?? 0; + if (lines[endLine].trim() !== '' && lineIndent < blockIndent) break; + endLine++; + } + + if (cursorLine >= endLine) return null; + + const queryLines = lines + .slice(queryLineIdx + 1, endLine) + .map((lineText) => (lineText.length >= blockIndent ? lineText.slice(blockIndent) : '')); + const queryText = queryLines.join('\n'); + + const cursorLineInQuery = cursorLine - (queryLineIdx + 1); + const cursorColInLine = Math.max(0, cursorOffset - lineStartOffsets[cursorLine] - blockIndent); + const offsetBeforeCursorLine = queryLines + .slice(0, cursorLineInQuery) + .reduce((acc, l) => acc + l.length + 1, 0); + + return { + propertyName, + queryText, + queryOffset: Math.max(0, Math.min(queryText.length, offsetBeforeCursorLine + cursorColInLine)), + }; +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/schema_utils.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/schema_utils.ts new file mode 100644 index 0000000000000..f1785e8b45356 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/schema_utils.ts @@ -0,0 +1,162 @@ +/* + * 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 { zodToJsonSchema } from 'zod-to-json-schema'; +import { createRuleDataSchema } from '../../../common/schemas/create_rule_data_schema'; +import type { JsonSchema, SchemaPropertyInfo } from './types'; + +/** + * Cached JSON schema converted from Zod + */ +let cachedJsonSchema: JsonSchema | null = null; + +/** + * Get the JSON schema for the rule data, converting from Zod and caching the result + */ +export const getJsonSchema = (): JsonSchema => { + if (!cachedJsonSchema) { + cachedJsonSchema = zodToJsonSchema(createRuleDataSchema, { + $refStrategy: 'none', + errorMessages: true, + }) as JsonSchema; + } + return cachedJsonSchema; +}; + +/** + * Resolve anyOf/oneOf/allOf to get the actual schema (handles optionals, unions, etc.) + */ +export const resolveSchema = (schema: JsonSchema): JsonSchema => { + if (schema.anyOf) { + const nonNull = schema.anyOf.find( + (s) => s.type !== 'null' && !(Array.isArray(s.type) && s.type.includes('null')) + ); + if (nonNull) return resolveSchema(nonNull); + return schema.anyOf[0] ? resolveSchema(schema.anyOf[0]) : schema; + } + if (schema.oneOf) { + const nonNull = schema.oneOf.find( + (s) => s.type !== 'null' && !(Array.isArray(s.type) && s.type.includes('null')) + ); + if (nonNull) return resolveSchema(nonNull); + return schema.oneOf[0] ? resolveSchema(schema.oneOf[0]) : schema; + } + if (schema.allOf && schema.allOf.length === 1) { + return resolveSchema(schema.allOf[0]); + } + return schema; +}; + +/** + * Get JSON schema node at a given path + */ +export const getSchemaNode = (path: Array): JsonSchema | undefined => { + let current: JsonSchema = getJsonSchema(); + + for (const segment of path) { + current = resolveSchema(current); + + if (typeof segment === 'number') { + if (current.items) { + current = current.items; + continue; + } + return undefined; + } + + if (current.properties && segment in current.properties) { + current = current.properties[segment]; + continue; + } + + return undefined; + } + + return current; +}; + +/** + * Get the type from a JSON schema + */ +export const getSchemaType = (schema: JsonSchema): SchemaPropertyInfo['type'] => { + const resolved = resolveSchema(schema); + const type = Array.isArray(resolved.type) ? resolved.type[0] : resolved.type; + + switch (type) { + case 'string': + return 'string'; + case 'number': + case 'integer': + return 'number'; + case 'boolean': + return 'boolean'; + case 'array': + return 'array'; + case 'object': + return 'object'; + default: + return 'unknown'; + } +}; + +/** + * Get properties from JSON schema at a given path + */ +export const getSchemaProperties = (path: string[]): SchemaPropertyInfo[] => { + const node = getSchemaNode(path); + if (!node) return []; + + const resolved = resolveSchema(node); + if (!resolved.properties) return []; + + return Object.entries(resolved.properties).map(([key, propSchema]) => { + const resolvedProp = resolveSchema(propSchema); + const type = getSchemaType(propSchema); + const isEnum = Boolean(resolvedProp.enum); + const enumValues = isEnum ? (resolvedProp.enum as string[]) : undefined; + + return { + key, + description: propSchema.description ?? resolvedProp.description, + type, + isEnum, + enumValues, + }; + }); +}; + +/** + * Get schema description at a given path + */ +export const getSchemaDescription = (path: string[]): string | undefined => { + const node = getSchemaNode(path); + if (!node) return undefined; + const resolved = resolveSchema(node); + return node.description ?? resolved.description; +}; + +/** + * Get schema type info at a given path (for value completions) + */ +export const getSchemaTypeInfo = ( + path: string[] +): { type: string; isBoolean: boolean; enumValues?: string[] } | undefined => { + const node = getSchemaNode(path); + if (!node) return undefined; + + const resolved = resolveSchema(node); + const type = Array.isArray(resolved.type) ? resolved.type[0] : resolved.type; + + if (type === 'boolean') { + return { type: 'boolean', isBoolean: true }; + } + if (resolved.enum) { + return { type: 'enum', isBoolean: false, enumValues: resolved.enum as string[] }; + } + + return { type: type ?? 'unknown', isBoolean: false }; +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/types.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/types.ts new file mode 100644 index 0000000000000..ca3c5eab27cdc --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/types.ts @@ -0,0 +1,81 @@ +/* + * 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 type { ESQLCallbacks } from '@kbn/esql-types'; + +/** + * Context information for ES|QL query within YAML + */ +export interface QueryContext { + /** The matched property name (e.g., 'query') */ + propertyName: string; + /** The ES|QL query text */ + queryText: string; + /** The cursor offset within the query text */ + queryOffset: number; +} + +/** + * Schema property information extracted from JSON schema + */ +export interface SchemaPropertyInfo { + key: string; + description?: string; + type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'unknown'; + isEnum?: boolean; + enumValues?: string[]; +} + +/** + * JSON Schema type definition + */ +export interface JsonSchema { + type?: string | string[]; + properties?: Record; + items?: JsonSchema; + description?: string; + enum?: Array; + anyOf?: JsonSchema[]; + oneOf?: JsonSchema[]; + allOf?: JsonSchema[]; + $ref?: string; + definitions?: Record; + $defs?: Record; + default?: unknown; +} + +/** + * Completion context for YAML editing + */ +export interface CompletionContext { + parentPath: string[]; + currentKey: string | null; + isValuePosition: boolean; +} + +/** + * Default property names that should be treated as ES|QL queries + */ +export const DEFAULT_ESQL_PROPERTY_NAMES = ['query']; + +/** + * Props for the YamlRuleEditor component + */ +export interface YamlRuleEditorProps { + value: string; + onChange: (value: string) => void; + esqlCallbacks: ESQLCallbacks; + /** + * Property names in YAML that should be treated as ES|QL queries. + * Values of these properties will get ES|QL syntax highlighting and auto-completion. + * @default ['query'] + */ + esqlPropertyNames?: string[]; + isReadOnly?: boolean; + height?: number; + dataTestSubj?: string; +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_rule_editor.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_rule_editor.tsx new file mode 100644 index 0000000000000..5d49e1a3d066d --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_rule_editor.tsx @@ -0,0 +1,254 @@ +/* + * 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 } from 'react'; +import { CodeEditorField } from '@kbn/code-editor'; +import { ESQL_AUTOCOMPLETE_TRIGGER_CHARS, monaco, YAML_LANG_ID } from '@kbn/monaco'; +import { suggest } from '@kbn/esql-language'; +import type { YamlRuleEditorProps } from './types'; +import { DEFAULT_ESQL_PROPERTY_NAMES } from './types'; +import { getSchemaDescription, getSchemaProperties, getSchemaTypeInfo } from './schema_utils'; +import { + buildYamlValidationMarkers, + getCompletionContext, + getExistingYamlKeys, + getYamlPathAtPosition, +} from './yaml_utils'; +import { findYamlQueryContext } from './query_context'; +import { ALERTING_V2_YAML_ESQL_LANG_ID, setEsqlPropertyNames } from './monaco_language'; + +/** + * Convert ES|QL suggestions to Monaco completion items + */ +const toCompletionItems = ( + suggestions: Array<{ + label: string; + text: string; + asSnippet?: boolean; + kind?: string; + detail?: string; + documentation?: string | { value: string }; + sortText?: string; + filterText?: string; + }>, + range: monaco.Range +): monaco.languages.CompletionItem[] => { + return suggestions.map((item) => { + const kind = + item.kind && item.kind in monaco.languages.CompletionItemKind + ? monaco.languages.CompletionItemKind[ + item.kind as keyof typeof monaco.languages.CompletionItemKind + ] + : monaco.languages.CompletionItemKind.Method; + return { + label: item.label, + insertText: item.text, + filterText: item.filterText, + kind, + detail: item.detail, + documentation: item.documentation, + sortText: item.sortText, + insertTextRules: item.asSnippet + ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + : undefined, + range, + }; + }); +}; + +/** + * YAML Rule Editor component with ES|QL support + * + * This component provides a Monaco-based code editor for editing rule definitions + * in YAML format with embedded ES|QL query support, including: + * - Syntax highlighting for both YAML and ES|QL + * - Auto-completion for YAML keys based on the rule schema + * - ES|QL auto-completion within the query field + * - Real-time validation against the rule schema + */ +export const YamlRuleEditor: React.FC = ({ + value, + onChange, + esqlCallbacks, + esqlPropertyNames = DEFAULT_ESQL_PROPERTY_NAMES, + isReadOnly = false, + height = 320, + dataTestSubj = 'alertingV2YamlRuleEditor', +}) => { + const editorSuggestDisposable = useRef(null); + const editorValidationDisposable = useRef(null); + + // Update the Monaco language configuration when property names change + useEffect(() => { + setEsqlPropertyNames(esqlPropertyNames); + }, [esqlPropertyNames]); + + const suggestionProvider = useMemo( + () => ({ + triggerCharacters: [...ESQL_AUTOCOMPLETE_TRIGGER_CHARS, ':', ' '], + provideCompletionItems: async (model, position) => { + const fullText = model.getValue(); + const cursorOffset = model.getOffsetAt(position); + const queryContext = findYamlQueryContext(fullText, cursorOffset, esqlPropertyNames); + + const word = model.getWordUntilPosition(position); + const range = new monaco.Range( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn + ); + + // If cursor is within ES|QL query, provide ES|QL suggestions + if (queryContext) { + const suggestions = await suggest( + queryContext.queryText, + queryContext.queryOffset, + esqlCallbacks + ); + return { + suggestions: toCompletionItems(suggestions, range), + }; + } + + // Otherwise, provide YAML key/value suggestions + const completionContext = getCompletionContext(fullText, position); + if (!completionContext) { + return { suggestions: [] }; + } + + // Value completions for boolean and enum types + if (completionContext.isValuePosition && completionContext.currentKey) { + const typeInfo = getSchemaTypeInfo([ + ...completionContext.parentPath, + completionContext.currentKey, + ]); + if (typeInfo?.isBoolean) { + return { + suggestions: ['true', 'false'].map((val) => ({ + label: val, + insertText: val, + kind: monaco.languages.CompletionItemKind.Value, + range, + })), + }; + } + if (typeInfo?.enumValues) { + return { + suggestions: typeInfo.enumValues.map((val) => ({ + label: val, + insertText: val, + kind: monaco.languages.CompletionItemKind.Value, + range, + })), + }; + } + return { suggestions: [] }; + } + + // Key completions - filter out keys that already exist + const properties = getSchemaProperties(completionContext.parentPath); + const existingKeys = getExistingYamlKeys(fullText, completionContext.parentPath); + + return { + suggestions: properties + .filter(({ key }) => !existingKeys.has(key)) + .map(({ key, description }) => ({ + label: key, + insertText: `${key}: `, + kind: monaco.languages.CompletionItemKind.Property, + documentation: description ? { value: description } : undefined, + range, + })), + }; + }, + }), + [esqlCallbacks, esqlPropertyNames] + ); + + const hoverProvider = useMemo( + () => ({ + provideHover: (model, position) => { + const path = getYamlPathAtPosition(model.getValue(), position, esqlPropertyNames); + if (!path) { + return null; + } + const description = getSchemaDescription(path); + if (!description) { + return null; + } + return { + contents: [{ value: description }], + }; + }, + }), + [esqlPropertyNames] + ); + + const handleEditorDidMount = useCallback( + (editor: monaco.editor.IStandaloneCodeEditor) => { + const model = editor.getModel(); + if (!model) { + return; + } + + // Force tokenization on initial render for the custom YAML+ESQL language + monaco.editor.setModelLanguage(model, YAML_LANG_ID); + monaco.editor.setModelLanguage(model, ALERTING_V2_YAML_ESQL_LANG_ID); + + // Auto-trigger suggestions when typing in query context + editorSuggestDisposable.current?.dispose(); + editorSuggestDisposable.current = editor.onDidChangeModelContent(() => { + const position = editor.getPosition(); + const currentModel = editor.getModel(); + if (!position || !currentModel) { + return; + } + const offset = currentModel.getOffsetAt(position); + if (findYamlQueryContext(currentModel.getValue(), offset, esqlPropertyNames)) { + editor.trigger('alerting_v2', 'editor.action.triggerSuggest', null); + } + }); + + // Set up validation markers + buildYamlValidationMarkers(model); + editorValidationDisposable.current?.dispose(); + editorValidationDisposable.current = editor.onDidChangeModelContent(() => { + buildYamlValidationMarkers(model); + }); + }, + [esqlPropertyNames] + ); + + const handleEditorWillUnmount = useCallback(() => { + editorSuggestDisposable.current?.dispose(); + editorSuggestDisposable.current = null; + editorValidationDisposable.current?.dispose(); + editorValidationDisposable.current = null; + }, []); + + return ( + + ); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_utils.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_utils.ts new file mode 100644 index 0000000000000..eb36319c66eb2 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_utils.ts @@ -0,0 +1,265 @@ +/* + * 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 { monaco } from '@kbn/monaco'; +import YAML, { LineCounter } from 'yaml'; +import { createRuleDataSchema } from '../../../common/schemas/create_rule_data_schema'; +import type { CompletionContext } from './types'; +import { findYamlQueryContext } from './query_context'; + +/** + * Get existing keys from YAML text at a given indent level + */ +export const getExistingYamlKeys = (text: string, parentPath: string[]): Set => { + const keys = new Set(); + const lines = text.split('\n'); + + if (parentPath.length === 0) { + for (const line of lines) { + const match = /^([A-Za-z0-9_]+)\s*:/.exec(line); + if (match) { + keys.add(match[1]); + } + } + } else { + let parentIndent = -1; + let inParent = false; + + for (const line of lines) { + const keyMatch = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(line); + + if (keyMatch) { + const key = keyMatch[2]; + const keyIndent = keyMatch[1].length; + + if (!inParent && key === parentPath[parentPath.length - 1] && keyIndent === 0) { + inParent = true; + parentIndent = keyIndent; + continue; + } + + if (inParent) { + if (keyIndent <= parentIndent && line.trim() !== '') { + break; + } + if (keyIndent === parentIndent + 2) { + keys.add(key); + } + } + } + } + } + + return keys; +}; + +/** + * Get completion context for YAML editing at a given position + */ +export const getCompletionContext = ( + text: string, + position: monaco.Position +): CompletionContext | null => { + const lines = text.split('\n'); + const lineIndex = Math.max(0, position.lineNumber - 1); + const line = lines[lineIndex] ?? ''; + const indent = line.match(/^\s*/)?.[0].length ?? 0; + const keyMatch = /^(\s*)([A-Za-z0-9_]+)\s*:(.*)$/.exec(line); + + if (keyMatch) { + const keyIndent = keyMatch[1].length; + const key = keyMatch[2]; + const hasValue = keyMatch[3].trim().length > 0; + const isValuePosition = hasValue || position.column > keyMatch[0].indexOf(':') + 1; + const parentPath = keyIndent === 0 ? [] : getYamlPathAtPosition(text, position)?.slice(0, -1); + return { + parentPath: parentPath ?? [], + currentKey: key, + isValuePosition, + }; + } + + if (indent === 0) { + return { parentPath: [], currentKey: null, isValuePosition: false }; + } + + for (let i = lineIndex - 1; i >= 0; i--) { + const match = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(lines[i]); + if (!match) { + continue; + } + const parentIndent = match[1].length; + if (parentIndent < indent) { + return { parentPath: [match[2]], currentKey: null, isValuePosition: false }; + } + } + + return { parentPath: [], currentKey: null, isValuePosition: false }; +}; + +/** + * Find a YAML node for a given path in a parsed YAML document + */ +export const findYamlNodeForPath = (doc: YAML.Document, path: Array) => { + let node: YAML.Node | null | undefined = doc.contents; + let lastPair: YAML.Pair | null = null; + + for (const segment of path) { + if (YAML.isMap(node)) { + const pair = node.items.find( + (item) => YAML.isScalar(item.key) && item.key.value === segment + ) as YAML.Pair | undefined; + if (!pair) { + return { node: null, pair: null }; + } + lastPair = pair; + node = pair.value; + continue; + } + + if (YAML.isSeq(node) && typeof segment === 'number') { + const nextNode = node.items[segment] as YAML.Node | undefined; + if (!nextNode) { + return { node: null, pair: null }; + } + node = nextNode; + continue; + } + + return { node: null, pair: null }; + } + + return { node, pair: lastPair }; +}; + +/** + * Convert YAML line position to Monaco position + */ +export const toMonacoPosition = (linePos: { line: number; col: number }) => { + return { + lineNumber: linePos.line > 0 ? linePos.line : 1, + column: linePos.col > 0 ? linePos.col : 1, + }; +}; + +/** + * Get Monaco range from YAML offsets + */ +export const getRangeFromOffsets = (lineCounter: LineCounter, start: number, end: number) => { + const startPos = toMonacoPosition(lineCounter.linePos(start)); + const endPos = toMonacoPosition(lineCounter.linePos(end)); + return new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column); +}; + +/** + * Build validation markers for YAML content in Monaco editor + */ +export const buildYamlValidationMarkers = (model: monaco.editor.ITextModel) => { + const text = model.getValue(); + const lineCounter = new LineCounter(); + const doc = YAML.parseDocument(text, { lineCounter }); + const markers: monaco.editor.IMarkerData[] = []; + + for (const error of doc.errors) { + const [start, end] = error.pos ?? [0, 0]; + const range = getRangeFromOffsets(lineCounter, start, Math.max(end, start + 1)); + markers.push({ + message: error.message, + severity: monaco.MarkerSeverity.Error, + startLineNumber: range.startLineNumber, + startColumn: range.startColumn, + endLineNumber: range.endLineNumber, + endColumn: range.endColumn, + }); + } + + if (doc.errors.length === 0) { + const parsed = createRuleDataSchema.safeParse(doc.toJS()); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + const path = issue.path as Array; + const { node, pair } = findYamlNodeForPath(doc, path); + const rangeSource = pair?.key ?? node; + const range = rangeSource?.range + ? getRangeFromOffsets(lineCounter, rangeSource.range[0], rangeSource.range[1]) + : getRangeFromOffsets(lineCounter, 0, 1); + markers.push({ + message: issue.message, + severity: monaco.MarkerSeverity.Error, + startLineNumber: range.startLineNumber, + startColumn: range.startColumn, + endLineNumber: range.endLineNumber, + endColumn: range.endColumn, + }); + } + } + } + + monaco.editor.setModelMarkers(model, 'alertingV2YamlSchema', markers); +}; + +/** + * Get the YAML path at a given position in the text + * + * @param text - The full YAML text + * @param position - The Monaco position + * @param esqlPropertyNames - Property names that should be treated as ES|QL queries + */ +export const getYamlPathAtPosition = ( + text: string, + position: monaco.Position, + esqlPropertyNames?: string[] +): string[] | null => { + const lines = text.split('\n'); + const lineIndex = Math.max(0, position.lineNumber - 1); + const line = lines[lineIndex] ?? ''; + const indent = line.match(/^\s*/)?.[0].length ?? 0; + const keyMatch = /^\s*([A-Za-z0-9_]+)\s*:/.exec(line); + + if (keyMatch) { + const key = keyMatch[1]; + if (indent === 0) { + return [key]; + } + for (let i = lineIndex - 1; i >= 0; i--) { + const parentLine = lines[i]; + const parentMatch = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(parentLine); + if (!parentMatch) { + continue; + } + const parentIndent = parentMatch[1].length; + if (parentIndent < indent) { + return [parentMatch[2], key]; + } + } + return [key]; + } + + const cursorOffset = lines.slice(0, lineIndex).reduce((acc, curr) => acc + curr.length + 1, 0); + const queryContext = findYamlQueryContext( + text, + cursorOffset + position.column - 1, + esqlPropertyNames + ); + if (queryContext) { + return [queryContext.propertyName]; + } + + for (let i = lineIndex - 1; i >= 0; i--) { + const parentLine = lines[i]; + const parentMatch = /^(\s*)([A-Za-z0-9_]+)\s*:/.exec(parentLine); + if (!parentMatch) { + continue; + } + const parentIndent = parentMatch[1].length; + if (parentIndent < indent) { + return [parentMatch[2]]; + } + } + + return null; +}; From 3f99acfbf262411204342278a0974eb1cca5e168 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Thu, 22 Jan 2026 15:50:01 +0000 Subject: [PATCH 6/8] show data type --- .../yaml_rule_editor/schema_utils.ts | 42 ++++++++++++++++++- .../components/yaml_rule_editor/types.ts | 3 +- .../yaml_rule_editor/yaml_rule_editor.tsx | 32 ++++++++++---- 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/schema_utils.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/schema_utils.ts index f1785e8b45356..2357775eb812a 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/schema_utils.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/schema_utils.ts @@ -103,6 +103,29 @@ export const getSchemaType = (schema: JsonSchema): SchemaPropertyInfo['type'] => } }; +/** + * Format type for display (e.g., "string", "number", "array", etc.) + */ +const formatTypeForDisplay = (schema: JsonSchema): string => { + const resolved = resolveSchema(schema); + const baseType = Array.isArray(resolved.type) ? resolved.type[0] : resolved.type; + + if (resolved.enum) { + const enumValues = resolved.enum as Array; + if (enumValues.length <= 3) { + return enumValues.map((v) => (typeof v === 'string' ? `"${v}"` : String(v))).join(' | '); + } + return `enum (${enumValues.length} values)`; + } + + if (baseType === 'array' && resolved.items) { + const itemType = formatTypeForDisplay(resolved.items); + return `${itemType}[]`; + } + + return baseType ?? 'unknown'; +}; + /** * Get properties from JSON schema at a given path */ @@ -115,7 +138,7 @@ export const getSchemaProperties = (path: string[]): SchemaPropertyInfo[] => { return Object.entries(resolved.properties).map(([key, propSchema]) => { const resolvedProp = resolveSchema(propSchema); - const type = getSchemaType(propSchema); + const type = formatTypeForDisplay(propSchema); const isEnum = Boolean(resolvedProp.enum); const enumValues = isEnum ? (resolvedProp.enum as string[]) : undefined; @@ -139,6 +162,23 @@ export const getSchemaDescription = (path: string[]): string | undefined => { return node.description ?? resolved.description; }; +/** + * Get full property info at a given path (for hover) + */ +export const getSchemaPropertyInfo = ( + path: string[] +): { type: string; description?: string; enumValues?: string[] } | undefined => { + const node = getSchemaNode(path); + if (!node) return undefined; + + const resolved = resolveSchema(node); + const type = formatTypeForDisplay(node); + const description = node.description ?? resolved.description; + const enumValues = resolved.enum ? (resolved.enum as string[]) : undefined; + + return { type, description, enumValues }; +}; + /** * Get schema type info at a given path (for value completions) */ diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/types.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/types.ts index ca3c5eab27cdc..caeb3c01c6451 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/types.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/types.ts @@ -25,7 +25,8 @@ export interface QueryContext { export interface SchemaPropertyInfo { key: string; description?: string; - type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'unknown'; + /** Formatted type string for display (e.g., "string", "number", "string[]", '"a" | "b"') */ + type: string; isEnum?: boolean; enumValues?: string[]; } diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_rule_editor.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_rule_editor.tsx index 5d49e1a3d066d..3866363c6c22f 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_rule_editor.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_rule_editor.tsx @@ -11,7 +11,7 @@ import { ESQL_AUTOCOMPLETE_TRIGGER_CHARS, monaco, YAML_LANG_ID } from '@kbn/mona import { suggest } from '@kbn/esql-language'; import type { YamlRuleEditorProps } from './types'; import { DEFAULT_ESQL_PROPERTY_NAMES } from './types'; -import { getSchemaDescription, getSchemaProperties, getSchemaTypeInfo } from './schema_utils'; +import { getSchemaProperties, getSchemaPropertyInfo, getSchemaTypeInfo } from './schema_utils'; import { buildYamlValidationMarkers, getCompletionContext, @@ -157,10 +157,11 @@ export const YamlRuleEditor: React.FC = ({ return { suggestions: properties .filter(({ key }) => !existingKeys.has(key)) - .map(({ key, description }) => ({ + .map(({ key, description, type }) => ({ label: key, insertText: `${key}: `, kind: monaco.languages.CompletionItemKind.Property, + detail: type, documentation: description ? { value: description } : undefined, range, })), @@ -177,13 +178,30 @@ export const YamlRuleEditor: React.FC = ({ if (!path) { return null; } - const description = getSchemaDescription(path); - if (!description) { + const propertyInfo = getSchemaPropertyInfo(path); + if (!propertyInfo) { return null; } - return { - contents: [{ value: description }], - }; + + const contents: monaco.IMarkdownString[] = []; + + // Show type as code block + contents.push({ + value: `\`\`\`yaml\n${path[path.length - 1]}: ${propertyInfo.type}\n\`\`\``, + }); + + // Show description if available + if (propertyInfo.description) { + contents.push({ value: propertyInfo.description }); + } + + // Show enum values if applicable + if (propertyInfo.enumValues && propertyInfo.enumValues.length > 3) { + const enumList = propertyInfo.enumValues.map((v) => `- \`${v}\``).join('\n'); + contents.push({ value: `**Allowed values:**\n${enumList}` }); + } + + return { contents }; }, }), [esqlPropertyNames] From 411afd15aaa81d39c4b32eb1dc06c15afb3fc25b Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Thu, 22 Jan 2026 16:18:10 +0000 Subject: [PATCH 7/8] remove common folder --- .../alerting-v2-schemas/src/index.ts | 1 + .../src/rule_data_schema.ts | 85 ++++++++++++------- .../alerting-v2-schemas/src}/validation.ts | 12 ++- .../common/schemas/create_rule_data_schema.ts | 57 ------------- .../shared/alerting_v2/common/types.ts | 11 --- .../public/components/create_rule_page.tsx | 3 +- .../yaml_rule_editor/schema_utils.ts | 2 +- .../components/yaml_rule_editor/yaml_utils.ts | 2 +- .../alerting_v2/public/services/rules_api.ts | 2 +- .../shared/alerting_v2/server/lib/duration.ts | 2 +- .../plugins/shared/alerting_v2/tsconfig.json | 3 +- 11 files changed, 70 insertions(+), 110 deletions(-) rename x-pack/platform/{plugins/shared/alerting_v2/common => packages/shared/response-ops/alerting-v2-schemas/src}/validation.ts (68%) delete mode 100644 x-pack/platform/plugins/shared/alerting_v2/common/schemas/create_rule_data_schema.ts delete mode 100644 x-pack/platform/plugins/shared/alerting_v2/common/types.ts diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/index.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/index.ts index 089a0c2f484b0..3b6a005bc5b06 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/index.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/index.ts @@ -6,3 +6,4 @@ */ export * from './rule_data_schema'; +export { validateDuration, validateEsqlQuery } from './validation'; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts index 970bba4105163..5f64a56f082c1 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts @@ -5,47 +5,57 @@ * 2.0. */ -import { Parser } from '@kbn/esql-language'; import { z } from '@kbn/zod'; - -const DURATION_RE = /^(\d+)(ms|s|m|h|d|w)$/; +import { validateDuration, validateEsqlQuery } from './validation'; const durationSchema = z.string().superRefine((value, ctx) => { - if (!DURATION_RE.test(value)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Invalid duration "${value}". Expected format like "5m", "1h", "30s", "250ms"`, - }); + const error = validateDuration(value); + if (error) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: error }); } }); -const withEsqlValidation = (schema: z.ZodString) => - schema.superRefine((query, ctx) => { - const errors = Parser.parseErrors(query); - if (errors.length > 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Invalid ES|QL query: ${errors[0].message}`, - }); +const esqlQuerySchema = z + .string() + .min(1) + .max(10000) + .superRefine((value, ctx) => { + const error = validateEsqlQuery(value); + if (error) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: error }); } }); const scheduleSchema = z .object({ - custom: durationSchema, + custom: durationSchema.describe('Rule execution interval (e.g. 1m, 5m).'), }) - .strict(); + .strict() + .describe('Schedule configuration for the rule.'); export const createRuleDataSchema = z .object({ - name: z.string().min(1).max(64), - tags: z.array(z.string().max(64)).max(100).default([]), + name: z.string().min(1).max(64).describe('Human-readable rule name.'), + tags: z + .array(z.string().max(64).describe('Rule tag.')) + .max(100) + .default([]) + .describe('Tags attached to the rule.'), schedule: scheduleSchema, - enabled: z.boolean().default(true), - query: withEsqlValidation(z.string().min(1).max(10000)), - timeField: z.string().min(1).max(128).default('@timestamp'), - lookbackWindow: durationSchema, - groupingKey: z.array(z.string()).max(16).default([]), + enabled: z.boolean().default(true).describe('Whether the rule is enabled.'), + query: esqlQuerySchema.describe('ES|QL query text to execute.'), + timeField: z + .string() + .min(1) + .max(128) + .default('@timestamp') + .describe('Time field to apply the lookback window to.'), + lookbackWindow: durationSchema.describe('Lookback window for the query (e.g. 5m, 1h).'), + groupingKey: z + .array(z.string()) + .max(16) + .default([]) + .describe('Fields to group alert events by.'), }) .strip(); @@ -53,14 +63,25 @@ export type CreateRuleData = z.infer; export const updateRuleDataSchema = z .object({ - name: z.string().min(1).optional(), - tags: z.array(z.string()).optional(), + name: z.string().min(1).max(64).optional().describe('Human-readable rule name.'), + tags: z.array(z.string().max(64)).max(100).optional().describe('Tags attached to the rule.'), schedule: scheduleSchema.optional(), - enabled: z.boolean().optional(), - query: withEsqlValidation(z.string().min(1)).optional(), - timeField: z.string().min(1).optional(), - lookbackWindow: durationSchema.optional(), - groupingKey: z.array(z.string()).optional(), + enabled: z.boolean().optional().describe('Whether the rule is enabled.'), + query: esqlQuerySchema.optional().describe('ES|QL query text to execute.'), + timeField: z + .string() + .min(1) + .max(128) + .optional() + .describe('Time field to apply the lookback window to.'), + lookbackWindow: durationSchema + .optional() + .describe('Lookback window for the query (e.g. 5m, 1h).'), + groupingKey: z + .array(z.string()) + .max(16) + .optional() + .describe('Fields to group alert events by.'), }) .strip(); diff --git a/x-pack/platform/plugins/shared/alerting_v2/common/validation.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/validation.ts similarity index 68% rename from x-pack/platform/plugins/shared/alerting_v2/common/validation.ts rename to x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/validation.ts index 5bc4be445de12..70273f48b7060 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/common/validation.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/validation.ts @@ -9,15 +9,23 @@ import { Parser } from '@kbn/esql-language'; const DURATION_RE = /^(\d+)(ms|s|m|h|d|w)$/; +/** + * Validate a duration string format (e.g., "5m", "1h", "30s", "250ms") + * @returns Error message if invalid, undefined if valid + */ export function validateDuration(value: string): string | void { if (!DURATION_RE.test(value)) { return `Invalid duration "${value}". Expected format like "5m", "1h", "30s", "250ms"`; } } -export const validateEsqlQuery = (query: string): string | void => { +/** + * Validate an ES|QL query string + * @returns Error message if invalid, undefined if valid + */ +export function validateEsqlQuery(query: string): string | void { const errors = Parser.parseErrors(query); if (errors.length > 0) { return `Invalid ES|QL query: ${errors[0].message}`; } -}; +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/common/schemas/create_rule_data_schema.ts b/x-pack/platform/plugins/shared/alerting_v2/common/schemas/create_rule_data_schema.ts deleted file mode 100644 index a119ccfdc1b0c..0000000000000 --- a/x-pack/platform/plugins/shared/alerting_v2/common/schemas/create_rule_data_schema.ts +++ /dev/null @@ -1,57 +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 { z } from '@kbn/zod'; -import { validateDuration, validateEsqlQuery } from '../validation'; - -const durationSchema = z.string().superRefine((value, ctx) => { - const error = validateDuration(value); - if (error) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: error }); - } -}); - -const esqlQuerySchema = z - .string() - .min(1) - .max(10000) - .superRefine((value, ctx) => { - const error = validateEsqlQuery(value); - if (error) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: error }); - } - }); - -export const createRuleDataSchema = z - .object({ - name: z.string().min(1).max(64).describe('Human-readable rule name.'), - tags: z - .array(z.string().max(64).describe('Rule tag.')) - .max(100) - .default([]) - .describe('Tags attached to the rule.'), - schedule: z - .object({ - custom: durationSchema.describe('Rule execution interval (e.g. 1m, 5m).'), - }) - .describe('Schedule configuration for the rule.'), - enabled: z.boolean().default(true).describe('Whether the rule is enabled.'), - query: esqlQuerySchema.describe('ES|QL query text to execute.'), - timeField: z - .string() - .min(1) - .max(128) - .default('@timestamp') - .describe('Time field to apply the lookback window to.'), - lookbackWindow: durationSchema.describe('Lookback window for the query (e.g. 5m, 1h).'), - groupingKey: z - .array(z.string()) - .max(16) - .default([]) - .describe('Fields to group alert events by.'), - }) - .strip(); diff --git a/x-pack/platform/plugins/shared/alerting_v2/common/types.ts b/x-pack/platform/plugins/shared/alerting_v2/common/types.ts deleted file mode 100644 index 67347d639f43b..0000000000000 --- a/x-pack/platform/plugins/shared/alerting_v2/common/types.ts +++ /dev/null @@ -1,11 +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 type { TypeOf as ZodTypeOf } from '@kbn/zod'; -import type { createRuleDataSchema } from './schemas/create_rule_data_schema'; - -export type CreateRuleData = ZodTypeOf; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx index 055fb7e80f455..2933c13024be1 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx @@ -25,8 +25,7 @@ import { getESQLSources, getEsqlColumns } from '@kbn/esql-utils'; import { FormattedMessage } from '@kbn/i18n-react'; import { dump, load } from 'js-yaml'; import { useHistory, useParams } from 'react-router-dom'; -import { createRuleDataSchema } from '../../common/schemas/create_rule_data_schema'; -import type { CreateRuleData } from '../../common/types'; +import { createRuleDataSchema, type CreateRuleData } from '@kbn/alerting-v2-schemas'; import { RulesApi } from '../services/rules_api'; import { YamlRuleEditor } from './yaml_rule_editor'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/schema_utils.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/schema_utils.ts index 2357775eb812a..46c4cf666cb97 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/schema_utils.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/schema_utils.ts @@ -6,7 +6,7 @@ */ import { zodToJsonSchema } from 'zod-to-json-schema'; -import { createRuleDataSchema } from '../../../common/schemas/create_rule_data_schema'; +import { createRuleDataSchema } from '@kbn/alerting-v2-schemas'; import type { JsonSchema, SchemaPropertyInfo } from './types'; /** diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_utils.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_utils.ts index eb36319c66eb2..38fe4f56f3984 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_utils.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_utils.ts @@ -7,7 +7,7 @@ import { monaco } from '@kbn/monaco'; import YAML, { LineCounter } from 'yaml'; -import { createRuleDataSchema } from '../../../common/schemas/create_rule_data_schema'; +import { createRuleDataSchema } from '@kbn/alerting-v2-schemas'; import type { CompletionContext } from './types'; import { findYamlQueryContext } from './query_context'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/services/rules_api.ts b/x-pack/platform/plugins/shared/alerting_v2/public/services/rules_api.ts index 2fdacd9188600..3bcb1c3386fed 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/services/rules_api.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/services/rules_api.ts @@ -8,8 +8,8 @@ import { inject, injectable } from 'inversify'; import type { HttpStart } from '@kbn/core/public'; import { CoreStart } from '@kbn/core-di-browser'; +import type { CreateRuleData } from '@kbn/alerting-v2-schemas'; import { INTERNAL_ALERTING_V2_RULE_API_PATH } from '../constants'; -import type { CreateRuleData } from '../../common/types'; export interface RuleListItem { id: string; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/duration.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/duration.ts index 27a7206793132..67e4577299a6e 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/duration.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/duration.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { validateDuration } from '../../common/validation'; +import { validateDuration } from '@kbn/alerting-v2-schemas'; const DURATION_RE = /^(\d+)(ms|s|m|h|d|w)$/; diff --git a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json index ac9216084bf5f..33a917ee1a246 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json +++ b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json @@ -7,8 +7,7 @@ "server/**/*", // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 "server/**/*.json", - "public/**/*", - "common/**/*" + "public/**/*" ], "kbn_references": [ "@kbn/core", From 858dea375e81ab10218724073df5acb407e77ff6 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Thu, 22 Jan 2026 16:31:57 +0000 Subject: [PATCH 8/8] move editor to a package --- package.json | 1 + tsconfig.base.json | 2 ++ .../response-ops/yaml-rule-editor/README.md | 3 +++ .../response-ops/yaml-rule-editor/index.ts | 8 ++++++++ .../yaml-rule-editor/jest.config.js | 12 +++++++++++ .../yaml-rule-editor/kibana.jsonc | 7 +++++++ .../yaml-rule-editor/package.json | 7 +++++++ .../yaml-rule-editor/src}/index.ts | 0 .../yaml-rule-editor/src}/monaco_language.ts | 0 .../yaml-rule-editor/src}/query_context.ts | 0 .../yaml-rule-editor/src}/schema_utils.ts | 0 .../yaml-rule-editor/src}/types.ts | 0 .../src}/yaml_rule_editor.tsx | 0 .../yaml-rule-editor/src}/yaml_utils.ts | 0 .../yaml-rule-editor/tsconfig.json | 20 +++++++++++++++++++ .../public/components/create_rule_page.tsx | 2 +- .../plugins/shared/alerting_v2/tsconfig.json | 1 + yarn.lock | 12 +++++++---- 18 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 x-pack/platform/packages/shared/response-ops/yaml-rule-editor/README.md create mode 100644 x-pack/platform/packages/shared/response-ops/yaml-rule-editor/index.ts create mode 100644 x-pack/platform/packages/shared/response-ops/yaml-rule-editor/jest.config.js create mode 100644 x-pack/platform/packages/shared/response-ops/yaml-rule-editor/kibana.jsonc create mode 100644 x-pack/platform/packages/shared/response-ops/yaml-rule-editor/package.json rename x-pack/platform/{plugins/shared/alerting_v2/public/components/yaml_rule_editor => packages/shared/response-ops/yaml-rule-editor/src}/index.ts (100%) rename x-pack/platform/{plugins/shared/alerting_v2/public/components/yaml_rule_editor => packages/shared/response-ops/yaml-rule-editor/src}/monaco_language.ts (100%) rename x-pack/platform/{plugins/shared/alerting_v2/public/components/yaml_rule_editor => packages/shared/response-ops/yaml-rule-editor/src}/query_context.ts (100%) rename x-pack/platform/{plugins/shared/alerting_v2/public/components/yaml_rule_editor => packages/shared/response-ops/yaml-rule-editor/src}/schema_utils.ts (100%) rename x-pack/platform/{plugins/shared/alerting_v2/public/components/yaml_rule_editor => packages/shared/response-ops/yaml-rule-editor/src}/types.ts (100%) rename x-pack/platform/{plugins/shared/alerting_v2/public/components/yaml_rule_editor => packages/shared/response-ops/yaml-rule-editor/src}/yaml_rule_editor.tsx (100%) rename x-pack/platform/{plugins/shared/alerting_v2/public/components/yaml_rule_editor => packages/shared/response-ops/yaml-rule-editor/src}/yaml_utils.ts (100%) create mode 100644 x-pack/platform/packages/shared/response-ops/yaml-rule-editor/tsconfig.json diff --git a/package.json b/package.json index 25a783108a71e..d470ef1c9de79 100644 --- a/package.json +++ b/package.json @@ -1187,6 +1187,7 @@ "@kbn/workflows-ui": "link:src/platform/packages/shared/kbn-workflows-ui", "@kbn/workplace-ai-app": "link:x-pack/solutions/workplaceai/plugins/workplace_ai_app", "@kbn/xstate-utils": "link:src/platform/packages/shared/kbn-xstate-utils", + "@kbn/yaml-rule-editor": "link:x-pack/platform/packages/shared/response-ops/yaml-rule-editor", "@kbn/zod": "link:src/platform/packages/shared/kbn-zod", "@kbn/zod-helpers": "link:src/platform/packages/shared/kbn-zod-helpers", "@langchain/aws": "0.1.15", diff --git a/tsconfig.base.json b/tsconfig.base.json index 8c3c3dcb88831..751ea7ec9fb67 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2508,6 +2508,8 @@ "@kbn/workspaces/*": ["src/platform/packages/shared/kbn-workspaces/*"], "@kbn/xstate-utils": ["src/platform/packages/shared/kbn-xstate-utils"], "@kbn/xstate-utils/*": ["src/platform/packages/shared/kbn-xstate-utils/*"], + "@kbn/yaml-rule-editor": ["x-pack/platform/packages/shared/response-ops/yaml-rule-editor"], + "@kbn/yaml-rule-editor/*": ["x-pack/platform/packages/shared/response-ops/yaml-rule-editor/*"], "@kbn/yarn-lock-validator": ["packages/kbn-yarn-lock-validator"], "@kbn/yarn-lock-validator/*": ["packages/kbn-yarn-lock-validator/*"], "@kbn/zod": ["src/platform/packages/shared/kbn-zod"], diff --git a/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/README.md b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/README.md new file mode 100644 index 0000000000000..a3311e3ea1cac --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/README.md @@ -0,0 +1,3 @@ +# @kbn/yaml-rule-editor + +YAML editor for alerting v2 rules. diff --git a/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/index.ts b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/index.ts new file mode 100644 index 0000000000000..3b2a320ae181f --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/index.ts @@ -0,0 +1,8 @@ +/* + * 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 * from './src'; diff --git a/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/jest.config.js b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/jest.config.js new file mode 100644 index 0000000000000..b0e7842040c4f --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/jest.config.js @@ -0,0 +1,12 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../../..', + roots: ['/x-pack/platform/packages/shared/response-ops/yaml-rule-editor'], +}; diff --git a/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/kibana.jsonc b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/kibana.jsonc new file mode 100644 index 0000000000000..5db8a82cf0dd1 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-browser", + "id": "@kbn/yaml-rule-editor", + "owner": "@elastic/response-ops", + "group": "platform", + "visibility": "shared" +} diff --git a/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/package.json b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/package.json new file mode 100644 index 0000000000000..a00e56e9d9d2d --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/yaml-rule-editor", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0", + "sideEffects": false +} \ No newline at end of file diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/index.ts b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/src/index.ts similarity index 100% rename from x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/index.ts rename to x-pack/platform/packages/shared/response-ops/yaml-rule-editor/src/index.ts diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/monaco_language.ts b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/src/monaco_language.ts similarity index 100% rename from x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/monaco_language.ts rename to x-pack/platform/packages/shared/response-ops/yaml-rule-editor/src/monaco_language.ts diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/query_context.ts b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/src/query_context.ts similarity index 100% rename from x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/query_context.ts rename to x-pack/platform/packages/shared/response-ops/yaml-rule-editor/src/query_context.ts diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/schema_utils.ts b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/src/schema_utils.ts similarity index 100% rename from x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/schema_utils.ts rename to x-pack/platform/packages/shared/response-ops/yaml-rule-editor/src/schema_utils.ts diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/types.ts b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/src/types.ts similarity index 100% rename from x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/types.ts rename to x-pack/platform/packages/shared/response-ops/yaml-rule-editor/src/types.ts diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_rule_editor.tsx b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/src/yaml_rule_editor.tsx similarity index 100% rename from x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_rule_editor.tsx rename to x-pack/platform/packages/shared/response-ops/yaml-rule-editor/src/yaml_rule_editor.tsx diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_utils.ts b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/src/yaml_utils.ts similarity index 100% rename from x-pack/platform/plugins/shared/alerting_v2/public/components/yaml_rule_editor/yaml_utils.ts rename to x-pack/platform/packages/shared/response-ops/yaml-rule-editor/src/yaml_utils.ts diff --git a/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/tsconfig.json b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/tsconfig.json new file mode 100644 index 0000000000000..edd8d6818d987 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/yaml-rule-editor/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/alerting-v2-schemas", + "@kbn/code-editor", + "@kbn/esql-language", + "@kbn/esql-types", + "@kbn/monaco" + ] +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx index 2933c13024be1..340fac604dbf1 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx @@ -26,8 +26,8 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { dump, load } from 'js-yaml'; import { useHistory, useParams } from 'react-router-dom'; import { createRuleDataSchema, type CreateRuleData } from '@kbn/alerting-v2-schemas'; +import { YamlRuleEditor } from '@kbn/yaml-rule-editor'; import { RulesApi } from '../services/rules_api'; -import { YamlRuleEditor } from './yaml_rule_editor'; const DEFAULT_RULE_YAML = `name: Example rule tags: [] diff --git a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json index 33a917ee1a246..1ca74f2dcf764 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json +++ b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json @@ -36,6 +36,7 @@ "@kbn/i18n-react", "@kbn/monaco", "@kbn/esql-types", + "@kbn/yaml-rule-editor", "@kbn/core-http-server-mocks", "@kbn/core-security-common", "@kbn/core-elasticsearch-server-mocks", diff --git a/yarn.lock b/yarn.lock index 2b1cddcb49cce..b4e8f85eabe76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4674,10 +4674,6 @@ version "0.0.0" uid "" -"@kbn/alerting-v2-schemas@link:x-pack/platform/packages/shared/response-ops/alerting-v2-schemas": - version "0.0.0" - uid "" - "@kbn/alerting-rule-utils@link:x-pack/platform/packages/shared/alerting-rule-utils": version "0.0.0" uid "" @@ -4694,6 +4690,10 @@ version "0.0.0" uid "" +"@kbn/alerting-v2-schemas@link:x-pack/platform/packages/shared/response-ops/alerting-v2-schemas": + version "0.0.0" + uid "" + "@kbn/alerts-as-data-utils@link:src/platform/packages/shared/kbn-alerts-as-data-utils": version "0.0.0" uid "" @@ -9534,6 +9534,10 @@ version "0.0.0" uid "" +"@kbn/yaml-rule-editor@link:x-pack/platform/packages/shared/response-ops/yaml-rule-editor": + version "0.0.0" + uid "" + "@kbn/yarn-lock-validator@link:packages/kbn-yarn-lock-validator": version "0.0.0" uid ""