Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 106 additions & 3 deletions backend/modules/mcp/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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, <enrichmentNodeId>.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"]
},
Expand Down
64 changes: 58 additions & 6 deletions frontend/src/features/soar/components/FlowEditor.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 extends keyof FlowFormState>(k: K, v: FlowFormState[K]) => setForm((f) => ({ ...f, [k]: v }))
const set = <K extends keyof FlowFormState>(k: K, v: FlowFormState[K]) => {
dirtyRef.current = true
setForm((f) => ({ ...f, [k]: v }))
}

useEffect(() => {
clearHttpBodyErrors()
Expand All @@ -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
Expand Down Expand Up @@ -202,6 +228,19 @@ export function FlowEditor({
{!creating && <p className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">{flow?.relPath}</p>}
</div>
<div className="flex shrink-0 items-center gap-2">
{!creating && !readOnly && aiConfigured && (
<button
type="button"
onClick={() => {
setSoarEditTarget({ relPath: flow!.relPath, name: form.name.trim() || flow!.name })
openPanel('soar-edit')
}}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-3 text-xs font-medium text-primary transition-colors hover:bg-primary/10"
>
<Sparkles size={13} />
{t('dashboards.actions.editWithAi')}
</button>
)}
<div className="inline-flex rounded-md border border-border p-0.5">
<button
type="button"
Expand All @@ -226,7 +265,14 @@ export function FlowEditor({

{mode === 'code' ? (
<div className="flex min-h-0 flex-1 flex-col p-6">
<YamlCodeEditor value={yaml} onChange={setYaml} readOnly={readOnly} />
<YamlCodeEditor
value={yaml}
onChange={(v) => {
dirtyRef.current = true
setYaml(v)
}}
readOnly={readOnly}
/>
</div>
) : (
<div className="min-h-0 flex-1 overflow-hidden bg-muted/10 p-4">
Expand All @@ -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)}
/>
</div>
Expand All @@ -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)}
/>
)}
Expand Down
114 changes: 114 additions & 0 deletions frontend/src/features/soar/components/SoarCreateDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm" {...backdrop}>
<div className="flex w-full max-w-md flex-col overflow-hidden rounded-xl border border-border bg-card shadow-xl">
<header className="flex items-start justify-between gap-4 border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold">{t('soar.create.title')}</h2>
<button type="button" onClick={onClose} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">
<X size={16} />
</button>
</header>

<div className="flex gap-2 border-b border-border px-6 pt-4">
<button
type="button"
onClick={() => setMode('manual')}
className={cn(
'rounded-md border px-3 py-1.5 text-sm font-medium transition-colors',
mode === 'manual' ? 'border-primary/30 bg-primary/10 text-primary' : 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
{t('soar.create.modeManual')}
</button>
<button
type="button"
onClick={() => aiConfigured && setMode('ai')}
disabled={!aiConfigured}
title={aiConfigured ? undefined : t('soar.create.aiLocked')}
className={cn(
'flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors',
!aiConfigured ? 'cursor-not-allowed border-border text-muted-foreground/50' : mode === 'ai' ? 'border-primary/30 bg-primary/10 text-primary' : 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<Sparkles size={14} />
{t('soar.create.modeAi')}
</button>
</div>

<div className="space-y-4 px-6 py-5">
<div>
<label className="mb-1.5 block text-xs font-medium text-foreground/80">{t('soar.create.name')}</label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t('soar.create.namePlaceholder')} autoFocus />
</div>
{mode === 'ai' && (
<div>
<label className="mb-1.5 block text-xs font-medium text-foreground/80">{t('soar.create.aiDescriptionLabel')}</label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('soar.create.aiDescriptionPlaceholder')} maxRows={8} />
</div>
)}
</div>

<footer className="flex items-center justify-end gap-2 border-t border-border bg-muted/40 px-6 py-3">
<Button variant="outline" size="sm" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button size="sm" onClick={submit} disabled={!valid}>
{mode === 'ai' ? t('soar.create.aiCreate') : t('soar.create.manualContinue')}
</Button>
</footer>
</div>
</div>
)
}
Loading
Loading