From f678e5c8f545afaa5fa1c91ccec8dfc795f55355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 25 Sep 2026 11:49:34 -0600 Subject: [PATCH 1/7] fix[frontend](soar-flows): removed redundant llm action node --- frontend/src/features/soar/types/soar.types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/features/soar/types/soar.types.ts b/frontend/src/features/soar/types/soar.types.ts index 017edb6d8..37ecc820a 100644 --- a/frontend/src/features/soar/types/soar.types.ts +++ b/frontend/src/features/soar/types/soar.types.ts @@ -166,7 +166,6 @@ export const EXECUTOR_CATALOG: ExecutorMeta[] = [ }, }, { type: 'llm_enrich', label: 'LLM enrichment', kinds: ['enrichment'], paramsPlaceholder: { prompt: '' } }, - { type: 'llm_action', label: 'LLM action', kinds: ['executor'], paramsPlaceholder: { prompt: '' } }, { type: 'notify', label: 'Send notification', kinds: ['executor'], paramsPlaceholder: { message: '', type: 'INFO' } }, { type: 'incident', label: 'Open incident', kinds: ['executor'], paramsPlaceholder: { name: '', description: '' } }, { type: 'mail', label: 'Send email', kinds: ['executor'], paramsPlaceholder: { to: '', cc: '', subject: '', body: '' } }, From 78bb563f123d5ade9528b852186dab478c527c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 25 Sep 2026 12:53:17 -0600 Subject: [PATCH 2/7] fix[backend](mcp/soar): updated flow properties schema in mcp catalog --- backend/modules/mcp/catalog.json | 109 ++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 3 deletions(-) diff --git a/backend/modules/mcp/catalog.json b/backend/modules/mcp/catalog.json index 433d90c65..0ddd101a4 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", "oneOf":["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"] }, From 7de9f499d17eb93dc569a9086bbbbe798211bff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 25 Sep 2026 13:04:07 -0600 Subject: [PATCH 3/7] fix[frontend](soc-ai): drop dashboard chat scope when cleared outside dashboard view --- frontend/src/features/soc-ai/SocAiProvider.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/soc-ai/SocAiProvider.tsx b/frontend/src/features/soc-ai/SocAiProvider.tsx index 387bbc4bc..0170a9077 100644 --- a/frontend/src/features/soc-ai/SocAiProvider.tsx +++ b/frontend/src/features/soc-ai/SocAiProvider.tsx @@ -159,7 +159,15 @@ export function SocAiProvider({ children }: { children: ReactNode }) { const clear = useCallback((scope: SocAiScope) => { abortRef.current?.abort() setters[scope]([]) - }, []) + // A cleared dashboard thread has nothing left to be scoped by — drop the edit + // target and go back to the general panel so a leftover "Editing: " + // title can't outlive its history. Only when NOT on a dashboard page — there + // the scope is still live. + if ((scope === 'dashboard-edit' || scope === 'dashboard-create') && !location.pathname.startsWith('/dashboards')) { + setDashboardEditTarget(null) + setActiveScope('panel') + } + }, [location.pathname]) const patchMsg = useCallback((scope: SocAiScope, id: number, fn: (m: SocAiMessage) => SocAiMessage) => { setters[scope]((list) => list.map((m) => (m.id === id ? fn(m) : m))) From afe910d4ece4498cc74c3376b1d95ad665e5d399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 25 Sep 2026 14:53:56 -0600 Subject: [PATCH 4/7] feat[frontend](soar/assitant): Edit with AI in flow editor --- .../features/soar/components/FlowEditor.tsx | 64 +++++++++++++++++-- .../src/features/soc-ai/SocAiProvider.tsx | 36 ++++++++++- .../features/soc-ai/components/SocAiPanel.tsx | 14 +++- frontend/src/shared/i18n/locales/de.json | 4 +- frontend/src/shared/i18n/locales/en.json | 4 +- frontend/src/shared/i18n/locales/es.json | 4 +- frontend/src/shared/i18n/locales/fr.json | 4 +- frontend/src/shared/i18n/locales/it.json | 4 +- frontend/src/shared/i18n/locales/pt.json | 4 +- frontend/src/shared/i18n/locales/ru.json | 4 +- 10 files changed, 125 insertions(+), 17 deletions(-) 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/soc-ai/SocAiProvider.tsx b/frontend/src/features/soc-ai/SocAiProvider.tsx index 0170a9077..0e4eef4e9 100644 --- a/frontend/src/features/soc-ai/SocAiProvider.tsx +++ b/frontend/src/features/soc-ai/SocAiProvider.tsx @@ -28,7 +28,12 @@ export interface SocAiMessage { // 'panel', 'dashboard-create' and 'dashboard-edit' all render in the floating // SocAiPanel (see activeScope) — separate threads, same UI. 'home' has its // own inline transcript (HomeChatTranscript) and never shows in the panel. -export type SocAiScope = 'panel' | 'home' | 'dashboard-create' | 'dashboard-edit' +export type SocAiScope = 'panel' | 'home' | 'dashboard-create' | 'dashboard-edit' | 'soar-edit' + +export interface SoarEditTarget { + relPath: string + name: string +} /** Which existing dashboard the 'dashboard-edit' thread is currently scoped to. */ export interface DashboardEditTarget { @@ -62,10 +67,14 @@ interface SocAiContextValue { homeMessages: SocAiMessage[] dashboardCreateMessages: SocAiMessage[] dashboardEditMessages: SocAiMessage[] + soarEditMessages: SocAiMessage[] dashboardEditTarget: DashboardEditTarget | null // Called right before opening the panel with scope 'dashboard-edit' so every // message sent in that thread carries which dashboard is being worked on. setDashboardEditTarget: (target: DashboardEditTarget | null) => void + soarEditTarget: SoarEditTarget | null + setSoarEditTarget: (target: SoarEditTarget | null) => void + soarEditVersion: number // The open item being shared with the agent, or null when nothing is open or // the person removed it from the conversation. focus: SocAiFocus | null @@ -114,7 +123,10 @@ export function SocAiProvider({ children }: { children: ReactNode }) { const [homeMessages, setHomeMessages] = useState([]) const [dashboardCreateMessages, setDashboardCreateMessages] = useState([]) const [dashboardEditMessages, setDashboardEditMessages] = useState([]) + const [soarEditMessages, setSoarEditMessages] = useState([]) const [dashboardEditTarget, setDashboardEditTarget] = useState(null) + const [soarEditTarget, setSoarEditTarget] = useState(null) + const [soarEditVersion, setSoarEditVersion] = useState(0) const [openItem, setOpenItem] = useState(null) // The item the person chose to stop sharing. Reset when nothing is open, so // the next time that item opens it is shared again. @@ -131,12 +143,14 @@ export function SocAiProvider({ children }: { children: ReactNode }) { home: setHomeMessages, 'dashboard-create': setDashboardCreateMessages, 'dashboard-edit': setDashboardEditMessages, + 'soar-edit': setSoarEditMessages, } const messagesByScope: Record = { panel: messages, home: homeMessages, 'dashboard-create': dashboardCreateMessages, 'dashboard-edit': dashboardEditMessages, + 'soar-edit': soarEditMessages, } const openKey = openItem ? `${openItem.kind}:${openItem.id}` : null @@ -167,6 +181,10 @@ export function SocAiProvider({ children }: { children: ReactNode }) { setDashboardEditTarget(null) setActiveScope('panel') } + if (scope === 'soar-edit' && !location.pathname.startsWith('/soar')) { + setSoarEditTarget(null) + setActiveScope('panel') + } }, [location.pathname]) const patchMsg = useCallback((scope: SocAiScope, id: number, fn: (m: SocAiMessage) => SocAiMessage) => { @@ -207,7 +225,9 @@ export function SocAiProvider({ children }: { children: ReactNode }) { const page = scope === 'dashboard-edit' && dashboardEditTarget ? `Dashboard editor — the user is editing dashboard "${dashboardEditTarget.name}" (dashboard id: ${dashboardEditTarget.id}). Use the dashboards/visualizations tools with this id to add, update, or remove its widgets; check what's already there first (dashboards.get / visualizations.list) before changing it.` - : scope === 'panel' + : scope === 'soar-edit' && soarEditTarget + ? `SOAR flow editor — user is editing flow "${soarEditTarget.name}" at ${soarEditTarget.relPath}. Call soar.rule.get first, then soar.rule.update with the FULL rule JSON (Conditions + Nodes map); preserve all unrelated nodes.` + : scope === 'panel' ? composePage(pageContext(location.pathname), focusRef.current) : pageContext(location.pathname) const lang = (i18n.language || 'en').split('-')[0] @@ -246,6 +266,9 @@ export function SocAiProvider({ children }: { children: ReactNode }) { void queryClient.invalidateQueries({ queryKey: DASHBOARDS_QUERY_KEYS.all }) void queryClient.invalidateQueries({ queryKey: VISUALIZATIONS_QUERY_KEYS.all }) } + if (scope === 'soar-edit' && (ev.kind === 'final' || ev.kind === 'error')) { + setSoarEditVersion((v) => v + 1) + } }, ac.signal, ).catch((err) => { @@ -267,7 +290,9 @@ export function SocAiProvider({ children }: { children: ReactNode }) { homeMessages, dashboardCreateMessages, dashboardEditMessages, + soarEditMessages, dashboardEditTarget, + soarEditTarget, queryClient, ], ) @@ -281,8 +306,12 @@ export function SocAiProvider({ children }: { children: ReactNode }) { homeMessages, dashboardCreateMessages, dashboardEditMessages, + soarEditMessages, dashboardEditTarget, setDashboardEditTarget, + soarEditTarget, + setSoarEditTarget, + soarEditVersion, focus, setFocus, detachFocus, @@ -301,7 +330,10 @@ export function SocAiProvider({ children }: { children: ReactNode }) { homeMessages, dashboardCreateMessages, dashboardEditMessages, + soarEditMessages, dashboardEditTarget, + soarEditTarget, + soarEditVersion, focus, setFocus, detachFocus, diff --git a/frontend/src/features/soc-ai/components/SocAiPanel.tsx b/frontend/src/features/soc-ai/components/SocAiPanel.tsx index a2313b3a6..0872a237b 100644 --- a/frontend/src/features/soc-ai/components/SocAiPanel.tsx +++ b/frontend/src/features/soc-ai/components/SocAiPanel.tsx @@ -16,12 +16,13 @@ import { MessageRow } from "./MessageRow"; // Panel-visible scopes only — 'home' has its own inline transcript and never // shows here, so it needs no title/empty-state copy in this map. const SCOPE_TITLE_KEY: Record< - "panel" | "dashboard-create" | "dashboard-edit", + "panel" | "dashboard-create" | "dashboard-edit" | "soar-edit", string > = { panel: "socAi.chat.title", "dashboard-create": "socAi.chat.dashboardCreateTitle", "dashboard-edit": "socAi.chat.dashboardEditTitle", + "soar-edit": "socAi.chat.soarEditTitle", }; export function SocAiPanel() { @@ -33,7 +34,9 @@ export function SocAiPanel() { messages, dashboardCreateMessages, dashboardEditMessages, + soarEditMessages, dashboardEditTarget, + soarEditTarget, focus, detachFocus, closePanel, @@ -59,7 +62,9 @@ export function SocAiPanel() { ? dashboardCreateMessages : activeScope === "dashboard-edit" ? dashboardEditMessages - : messages; + : activeScope === "soar-edit" + ? soarEditMessages + : messages; // 'home' never opens this panel (see the comment above), so it has no // entry here — fall back to the general panel title if it ever does. const titleKey = @@ -114,6 +119,11 @@ export function SocAiPanel() { })}

)} + {activeScope === "soar-edit" && soarEditTarget && ( +

+ {t("socAi.chat.editingFlow", { name: soarEditTarget.name })} +

+ )}
Date: Fri, 25 Sep 2026 14:58:31 -0600 Subject: [PATCH 5/7] feat[frontend](soar): click soar-edit panel header to open the active flow --- .../src/features/soc-ai/components/SocAiPanel.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/soc-ai/components/SocAiPanel.tsx b/frontend/src/features/soc-ai/components/SocAiPanel.tsx index 0872a237b..3e4cb2b29 100644 --- a/frontend/src/features/soc-ai/components/SocAiPanel.tsx +++ b/frontend/src/features/soc-ai/components/SocAiPanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { ArrowUp, @@ -47,6 +48,7 @@ export function SocAiPanel() { const [draft, setDraft] = useState(""); const scrollRef = useRef(null); const taRef = useRef(null); + const navigate = useNavigate(); // Auto-grow the input, capped at 7 lines (max-h-[140px]) so long prompts // scroll internally instead of eating the message area. @@ -120,9 +122,18 @@ export function SocAiPanel() {

)} {activeScope === "soar-edit" && soarEditTarget && ( -

+ )}

From b0309faf47e10da2914176d8cdeed4c7295da4d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 25 Sep 2026 15:06:04 -0600 Subject: [PATCH 6/7] feat[frontend](soar): create flow with AI --- .../soar/components/SoarCreateDialog.tsx | 114 ++++++++++++++++++ .../src/features/soar/pages/FlowsPage.tsx | 21 +++- .../src/features/soc-ai/SocAiProvider.tsx | 31 ++++- .../features/soc-ai/components/SocAiPanel.tsx | 8 +- frontend/src/shared/i18n/locales/de.json | 15 ++- frontend/src/shared/i18n/locales/en.json | 15 ++- frontend/src/shared/i18n/locales/es.json | 15 ++- frontend/src/shared/i18n/locales/fr.json | 15 ++- frontend/src/shared/i18n/locales/it.json | 15 ++- frontend/src/shared/i18n/locales/pt.json | 15 ++- frontend/src/shared/i18n/locales/ru.json | 15 ++- 11 files changed, 266 insertions(+), 13 deletions(-) create mode 100644 frontend/src/features/soar/components/SoarCreateDialog.tsx 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' && ( +
+ +