diff --git a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_page.tsx b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_page.tsx index 6e24e241dce9b..764958bd80106 100644 --- a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_page.tsx +++ b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_page.tsx @@ -9,21 +9,21 @@ import type { UseEuiTheme } from '@elastic/eui'; import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { useMemoCss } from '@kbn/css-utils/public/use_memo_css'; import { i18n } from '@kbn/i18n'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { WORKFLOWS_UI_VISUAL_EDITOR_SETTING_ID } from '@kbn/workflows'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { css } from '@emotion/react'; -import { useMemoCss } from '@kbn/css-utils/public/use_memo_css'; +import { useWorkflowActions } from '../../../entities/workflows/model/use_workflow_actions'; import { useWorkflowDetail } from '../../../entities/workflows/model/useWorkflowDetail'; +import { useWorkflowExecution } from '../../../entities/workflows/model/useWorkflowExecution'; +import { TestWorkflowModal } from '../../../features/run_workflow/ui/test_workflow_modal'; +import { WorkflowEventModal } from '../../../features/run_workflow/ui/workflow_event_modal'; +import { WorkflowExecutionDetail } from '../../../features/workflow_execution_detail'; import { WorkflowExecutionList } from '../../../features/workflow_execution_list/ui/workflow_execution_list_stateful'; import { useWorkflowUrlState } from '../../../hooks/use_workflow_url_state'; -import { WorkflowExecutionDetail } from '../../../features/workflow_execution_detail'; -import { useWorkflowExecution } from '../../../entities/workflows/model/useWorkflowExecution'; import { WorkflowDetailHeader } from './workflow_detail_header'; -import { useWorkflowActions } from '../../../entities/workflows/model/use_workflow_actions'; -import { WorkflowEventModal } from '../../../features/run_workflow/ui/workflow_event_modal'; -import { TestWorkflowModal } from '../../../features/run_workflow/ui/test_workflow_modal'; const WorkflowYAMLEditor = React.lazy(() => import('../../../widgets/workflow_yaml_editor').then((module) => ({ diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx index ec612d9608ded..79044a35ac989 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx @@ -9,28 +9,71 @@ import type { UseEuiTheme } from '@elastic/eui'; import { EuiIcon, useEuiTheme } from '@elastic/eui'; -import { monaco } from '@kbn/monaco'; -import type { EsWorkflowStepExecution } from '@kbn/workflows'; -import { getJsonSchemaFromYamlSchema } from '@kbn/workflows'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { css } from '@emotion/react'; import { useMemoCss } from '@kbn/css-utils/public/use_memo_css'; -import YAML from 'yaml'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedRelative } from '@kbn/i18n-react'; +import { monaco } from '@kbn/monaco'; +import type { EsWorkflowStepExecution } from '@kbn/workflows'; +import { getJsonSchemaFromYamlSchema } from '@kbn/workflows'; import type { SchemasSettings } from 'monaco-yaml'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import YAML, { isPair, isScalar, visit } from 'yaml'; +import { getStepNode } from '../../../../common/lib/yaml_utils'; import { WORKFLOW_ZOD_SCHEMA, WORKFLOW_ZOD_SCHEMA_LOOSE } from '../../../../common/schema'; +import { UnsavedChangesPrompt } from '../../../shared/ui/unsaved_changes_prompt'; import { YamlEditor } from '../../../shared/ui/yaml_editor'; +import { getCompletionItemProvider } from '../lib/get_completion_item_provider'; import { useYamlValidation } from '../lib/use_yaml_validation'; import { getHighlightStepDecorations, getMonacoRangeFromYamlNode, navigateToErrorPosition, } from '../lib/utils'; -import { WorkflowYAMLValidationErrors } from './workflow_yaml_validation_errors'; -import { getCompletionItemProvider } from '../lib/get_completion_item_provider'; -import { getStepNode } from '../../../../common/lib/yaml_utils'; -import { UnsavedChangesPrompt } from '../../../shared/ui/unsaved_changes_prompt'; import type { YamlValidationError } from '../model/types'; +import { WorkflowYAMLValidationErrors } from './workflow_yaml_validation_errors'; + +const getTriggerNodes = ( + yamlDocument: YAML.Document +): Array<{ node: any; triggerType: string; typePair: any }> => { + const triggerNodes: Array<{ node: any; triggerType: string; typePair: any }> = []; + + if (!yamlDocument?.contents) return triggerNodes; + + visit(yamlDocument, { + Pair(key, pair, ancestors) { + if (!pair.key || !isScalar(pair.key) || pair.key.value !== 'type') { + return; + } + + // Check if this is a type field within a trigger + const path = ancestors.slice(); + let isTriggerType = false; + + // Walk up the ancestors to see if we're in a triggers array + for (let i = path.length - 1; i >= 0; i--) { + const ancestor = path[i]; + if (isPair(ancestor) && isScalar(ancestor.key) && ancestor.key.value === 'triggers') { + isTriggerType = true; + break; + } + } + + if (isTriggerType && isScalar(pair.value)) { + const triggerType = pair.value.value as string; + // Find the parent map node that contains this trigger + const triggerMapNode = ancestors[ancestors.length - 1]; + triggerNodes.push({ + node: triggerMapNode, + triggerType, + typePair: pair, // Store the actual type pair for precise positioning + }); + } + }, + }); + + return triggerNodes; +}; const WorkflowSchemaUri = 'file:///workflow-schema.json'; @@ -91,6 +134,8 @@ export const WorkflowYAMLEditor = ({ useRef(null); const stepExecutionsDecorationCollectionRef = useRef(null); + const alertTriggerDecorationCollectionRef = + useRef(null); const { error: errorValidating, @@ -235,6 +280,103 @@ export const WorkflowYAMLEditor = ({ editorRef.current?.createDecorationsCollection(decorations) ?? null; }, [isEditorMounted, stepExecutions, highlightStep, yamlDocument]); + useEffect(() => { + const model = editorRef.current?.getModel() ?? null; + if (alertTriggerDecorationCollectionRef.current) { + // clear existing decorations + alertTriggerDecorationCollectionRef.current.clear(); + } + + // Don't show alert dots when in executions view + if (!model || !yamlDocument || !isEditorMounted || readOnly) { + return; + } + + const triggerNodes = getTriggerNodes(yamlDocument); + const alertTriggers = triggerNodes.filter(({ triggerType }) => triggerType === 'alert'); + + if (alertTriggers.length === 0) { + return; + } + + const decorations = alertTriggers + .map(({ node, typePair }) => { + // Try to get the range from the typePair first, fallback to searching within the trigger node + let typeRange = getMonacoRangeFromYamlNode(model, typePair); + + if (!typeRange) { + // Fallback: use the trigger node range and search for the type line + const triggerRange = getMonacoRangeFromYamlNode(model, node); + if (!triggerRange) { + return null; + } + + // Find the specific line that contains "type:" and "alert" within this trigger + let typeLineNumber = triggerRange.startLineNumber; + for ( + let lineNum = triggerRange.startLineNumber; + lineNum <= triggerRange.endLineNumber; + lineNum++ + ) { + const lineContent = model.getLineContent(lineNum); + if (lineContent.includes('type:') && lineContent.includes('alert')) { + typeLineNumber = lineNum; + break; + } + } + + typeRange = { + startLineNumber: typeLineNumber, + endLineNumber: typeLineNumber, + startColumn: 1, + endColumn: model.getLineMaxColumn(typeLineNumber), + }; + } + + const glyphDecoration: monaco.editor.IModelDeltaDecoration = { + range: new monaco.Range( + typeRange.startLineNumber, + 1, + typeRange.startLineNumber, + model.getLineMaxColumn(typeRange.startLineNumber) + ), + options: { + glyphMarginClassName: 'alert-trigger-glyph', + glyphMarginHoverMessage: { + value: i18n.translate( + 'workflows.workflowDetail.yamlEditor.alertTriggerGlyphTooltip', + { + defaultMessage: + 'Alert trigger: This workflow will be executed automatically only when connected to a rule via the "Run Workflow" action.', + } + ), + }, + }, + }; + + const lineHighlightDecoration: monaco.editor.IModelDeltaDecoration = { + range: new monaco.Range( + typeRange.startLineNumber, + 1, + typeRange.startLineNumber, + model.getLineMaxColumn(typeRange.startLineNumber) + ), + options: { + className: 'alert-trigger-highlight', + marginClassName: 'alert-trigger-highlight', + isWholeLine: true, + }, + }; + + return [glyphDecoration, lineHighlightDecoration]; + }) + .flat() + .filter((d) => d !== null) as monaco.editor.IModelDeltaDecoration[]; + + alertTriggerDecorationCollectionRef.current = + editorRef.current?.createDecorationsCollection(decorations) ?? null; + }, [isEditorMounted, yamlDocument, readOnly]); + const completionProvider = useMemo(() => { return getCompletionItemProvider(WORKFLOW_ZOD_SCHEMA_LOOSE); }, []); @@ -471,6 +613,19 @@ const componentStyles = { borderRadius: '50%', }, }, + '.alert-trigger-glyph': { + '&:before': { + content: '""', + display: 'block', + width: '12px', + height: '12px', + backgroundColor: euiTheme.colors.warning, + borderRadius: '50%', + }, + }, + '.alert-trigger-highlight': { + backgroundColor: euiTheme.colors.backgroundLightWarning, + }, }), editorContainer: css({ flex: '1 1 0',