diff --git a/backend/modules/mcp/catalog.json b/backend/modules/mcp/catalog.json index 433d90c65..b76ecf4ad 100644 --- a/backend/modules/mcp/catalog.json +++ b/backend/modules/mcp/catalog.json @@ -636,9 +636,112 @@ "platform": {"type": "string", "enum": ["windows", "linux", "macos"]}, "agent": {"type": "string", "description": "Target hostname; empty = auto-resolve from alert source"}, "excludedAgents": {"type": "array", "items": {"type": "string"}}, - "params": {"type": "object", "description": "Executor-specific JSON params (shape per executor type)"}, - "onSuccess": {"type": "array", "items": {"type": "string"}, "description": "Child node ids run when this node succeeds"}, - "onError": {"type": "array", "items": {"type": "string"}, "description": "Child node ids run when this node fails"} + "params": { + "description": "Executor-specific JSON params (shape per executor type)", + "oneOf": [ + { + "type": "object", + "description":"only usable for http node", + "properties": { + "method": { "type": "string" }, + "url": { "type": "string" }, + "headers": { + "type": "object", + "properties": { + "Content-Type": { "type": "string" }, + "Accept": { "type": "string" }, + "Accept-Encoding": { "type": "string" }, + "Connection": { "type": "string" } + }, + "required": ["Content-Type", "Accept", "Accept-Encoding", "Connection"] + }, + "body": { + "type": "object" + } + }, + "required": [ "method", "url", "headers"] + }, + { + "type": "object", + "description":"parameter set only for llm enrichment", + "properties": { + "prompt": { "type": "string" } + }, + "required": ["prompt"] + }, + { + "type": "object", + "description":"parameter set only for notification node", + "properties": { + "message": { "type": "string" }, + "type": { "type": "string","enum": ["INFO", "WARNING", "ERROR"]} + }, + "required": ["type", "message"] + }, + { + "type": "object", + "description":"parameter set only for incident node", + "properties": { + "type": { "const": "incident" }, + "description": { "type": "string" } + }, + "required": [ "name", "description"] + }, + { + "type": "object", + "description":"parameter set only for email node", + "properties": { + "to": { "type": "string" }, + "cc": { "type": "string" }, + "subject": { "type": "string" }, + "body": { "type": "string" } + }, + "required": [ "to", "cc", "subject", "body"] + }, + { + "type": "object", + "description":"parameter set only for conditional node", + "properties": { + "conditions": { + "type": ["array", "null"], + "minItems": 1, + "items": { + "type": "object", + "properties": { + "operator": { + "type": ["string", "null"], + "enum": [ + "IS", + "IS_NOT", + "CONTAINS", + "NOT_CONTAINS", + "EXISTS", + "NOT_EXISTS", + "START_WITH", + "NOT_START_WITH", + "ENDS_WITH", + "NOT_ENDS_WITH", + "IS_ONE_OF", + "IS_NOT_ONE_OF" + ], + "description": "Operator" + }, + "field": { + "type": ["string", "null"], + "description": "gjson path into context bag — e.g. alert.name, .result" + }, + "value": { + "description": "string; string[] for IS_ONE_OF / IS_NOT_ONE_OF; ignored by EXISTS/NOT_EXISTS" + } + }, + "required": ["operator", "field"] + } + } + }, + "required": ["conditions"] + } + ] + } }, "required": ["kind", "executor"] }, diff --git a/frontend/src/features/soar/components/FlowEditor.tsx b/frontend/src/features/soar/components/FlowEditor.tsx index eede88c31..4b94fcff3 100644 --- a/frontend/src/features/soar/components/FlowEditor.tsx +++ b/frontend/src/features/soar/components/FlowEditor.tsx @@ -1,11 +1,13 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Code2, LayoutList, Loader2, Lock, Pencil, Trash2, X } from 'lucide-react' +import { Code2, LayoutList, Loader2, Lock, Pencil, Sparkles, Trash2, X } from 'lucide-react' import { toast } from 'sonner' import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' import { YamlCodeEditor } from '@/shared/components/YamlCodeEditor' import { PlatformBroadcastButton, broadcast, BULK_PATHS } from '@/features/platform-broadcast' +import { useSocAi } from '@/features/soc-ai/SocAiProvider' +import { useSocAiConfigured } from '@/features/soc-ai/lib/useSocAiConfig' import { soarFlowsService, SoarHttpError } from '../services/soar-flows.service' import { flowToForm, formToInput, flowFormToYaml, yamlToFlowForm, type FlowFormState } from '../lib/flow-yaml' import { clearHttpBodyErrors, firstHttpBodyError, isValidHttpUrl } from '../lib/http-node-validity' @@ -33,8 +35,19 @@ export function FlowEditor({ const [busy, setBusy] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) const [identityOpen, setIdentityOpen] = useState(false) + const dirtyRef = useRef(false) + const { + setSoarEditTarget, + openPanel, + soarEditTarget, + soarEditVersion, + } = useSocAi() + const aiConfigured = useSocAiConfigured() - const set = (k: K, v: FlowFormState[K]) => setForm((f) => ({ ...f, [k]: v })) + const set = (k: K, v: FlowFormState[K]) => { + dirtyRef.current = true + setForm((f) => ({ ...f, [k]: v })) + } useEffect(() => { clearHttpBodyErrors() @@ -51,10 +64,23 @@ export function FlowEditor({ toast.error(t('soar.editor.yamlError', { error: r.error })) return } + dirtyRef.current = true setForm({ ...r.form, active: form.active }) setMode('visual') } + useEffect(() => { + if (creating || !flow || !soarEditTarget || soarEditVersion === 0) return + if (dirtyRef.current) return + let cancelled = false + soarFlowsService.get(flow.relPath).then((f) => { + if (cancelled || dirtyRef.current) return + setForm(flowToForm(f)) + if (mode === 'code') setYaml(flowFormToYaml(flowToForm(f))) + }).catch(() => {}) + return () => { cancelled = true } + }, [soarEditVersion]) + const save = async () => { if (busy) return let f = form @@ -202,6 +228,19 @@ export function FlowEditor({ {!creating &&

{flow?.relPath}

}
+ {!creating && !readOnly && aiConfigured && ( + + )}
) : (
@@ -235,7 +281,10 @@ export function FlowEditor({ nodes={form.nodes} conditions={form.conditions} readOnly={readOnly} - onChange={(patch) => setForm((f) => ({ ...f, roots: patch.roots, nodes: patch.nodes }))} + onChange={(patch) => { + dirtyRef.current = true + setForm((f) => ({ ...f, roots: patch.roots, nodes: patch.nodes })) + }} onConditionsChange={(c) => set('conditions', c)} />
@@ -247,7 +296,10 @@ export function FlowEditor({ description={form.description} maxDepth={form.maxDepth} readOnly={readOnly} - onChange={(patch) => setForm((f) => ({ ...f, ...patch }))} + onChange={(patch) => { + dirtyRef.current = true + setForm((f) => ({ ...f, ...patch })) + }} onClose={() => setIdentityOpen(false)} /> )} diff --git a/frontend/src/features/soar/components/SoarCreateDialog.tsx b/frontend/src/features/soar/components/SoarCreateDialog.tsx new file mode 100644 index 000000000..8dd01c46a --- /dev/null +++ b/frontend/src/features/soar/components/SoarCreateDialog.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Sparkles, X } from 'lucide-react' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import { Textarea } from '@/shared/components/ui/textarea' +import { useSocAi } from '@/features/soc-ai/SocAiProvider' +import { useSocAiConfigured } from '@/features/soc-ai/lib/useSocAiConfig' +import { useBackdropDismiss } from '@/shared/hooks/useBackdropDismiss' + +export function SoarCreateDialog({ + open, + onClose, + onManual, +}: { + open: boolean + onClose: () => void + onManual: () => void +}) { + const { t } = useTranslation() + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [mode, setMode] = useState<'manual' | 'ai'>('manual') + const aiConfigured = useSocAiConfigured() + const { openPanel, submit: submitToAssistant, setSoarCreateTarget } = useSocAi() + + useEffect(() => { + if (open) { + setName('') + setDescription('') + setMode('manual') + } + }, [open]) + + const backdrop = useBackdropDismiss(onClose) + + if (!open) return null + + const valid = name.trim().length > 0 && (mode === 'manual' || description.trim().length > 0) + + const submit = () => { + if (!valid) return + if (mode === 'ai') { + setSoarCreateTarget({ name: name.trim(), description: description.trim() }) + onClose() + openPanel('soar-create') + submitToAssistant(t('soar.create.aiOpener', { name: name.trim(), description: description.trim() }), { scope: 'soar-create' }) + return + } + onManual() + } + + return ( +
+
+
+

{t('soar.create.title')}

+ +
+ +
+ + +
+ +
+
+ + setName(e.target.value)} placeholder={t('soar.create.namePlaceholder')} autoFocus /> +
+ {mode === 'ai' && ( +
+ +